diff --git a/Cargo.lock b/Cargo.lock index dc6b79b..32c36a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5143,6 +5143,7 @@ version = "0.5.0" dependencies = [ "image", "js-sys", + "kurbo", "log", "serde", "serde_json", diff --git a/ROADMAP.md b/ROADMAP.md index 23b8ee3..f919d90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **453 / 470 (96.4%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **458 / 475 (96.4%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip | | 2026-10-11 | Content controls (`w:sdt`) kept, shown and edited; Developer tab, Design Mode, Properties dialog (#381) | | 2026-10-11 | Insert › Chart (#409): column, bar, line, area, pie, doughnut and scatter charts with Chart Design (type, Edit Data, title, legend, data labels), saved as real chart parts; catalog 408/439 (92.9%) | | 2026-10-11 | Line Numbers, Hyphenation (zone, CAPS, consecutive limit, Manual), Language, Envelopes and Labels dialogs (#407); hyphenation options kept in DOCX; 30 modal dialogs | diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs new file mode 100644 index 0000000..5f662a2 --- /dev/null +++ b/crates/doc/src/connector.rs @@ -0,0 +1,263 @@ +//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text +//! warp, and a connector's ends. +//! +//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line +//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be +//! glued to a connection site of another shape (`a:stCxn` / `a:endCxn`, ECMA-376 §20.1.2.2.36 and +//! §20.1.2.2.13: the shape's drawing id and a site index). When a glued shape moves, [`route`] +//! gives the connector's new frame from its ends. + +use serde::{Deserialize, Serialize}; + +use crate::graphic::PathSeg; +use crate::para::{GroupChild, InlineObject, ShapeKind}; +use crate::wordart::TextWarp; + +/// The rest of a shape's properties. Empty (the default) for most shapes. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShapeExtra { + /// The drawing id connectors refer to (`wp:docPr/@id`, `wps:cNvPr/@id`); 0 = none. Kept only + /// for shapes a connector is glued to. + #[serde(skip_serializing_if = "is_zero")] + pub id: u32, + /// The text's warp (Text Effects › Transform). + #[serde(skip_serializing_if = "Option::is_none")] + pub warp: Option, + /// What a connector's start and end are glued to. + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + /// Arrowheads at a line's or connector's start (`a:headEnd`) and end (`a:tailEnd`). + #[serde(skip_serializing_if = "is_false")] + pub arrow_start: bool, + #[serde(skip_serializing_if = "is_false")] + pub arrow_end: bool, +} + +fn is_zero(v: &u32) -> bool { + *v == 0 +} +fn is_false(v: &bool) -> bool { + !*v +} + +impl ShapeExtra { + pub fn is_empty(&self) -> bool { + *self == ShapeExtra::default() + } +} + +/// One glued end of a connector: the drawing id of the shape and its connection site index. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnEnd { + pub id: u32, + pub site: u32, +} + +/// Largest connection site index read from a file. +pub const MAX_SITE: u32 = 64; + +impl ShapeKind { + /// A connector (straight, elbow or curved): a line that can be glued to shapes. + pub fn is_connector(self) -> bool { + matches!(self, ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector) + } + /// A line or connector: drawn as an open path, never filled. + pub fn is_open(self) -> bool { + self == ShapeKind::Line || self.is_connector() + } +} + +/// The connection sites of a `kind` shape `w` × `h`, in its own unrotated frame, by index. The +/// order follows the preset shapes' connection lists (for a rectangle: top, left, bottom, right). +pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> { + let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h)); + let (cx, cy) = (w / 2.0, h / 2.0); + match kind { + ShapeKind::Ellipse => { + // The four sides, and the points of the ellipse at 45° between them. + let (dx, dy) = (cx * std::f32::consts::FRAC_1_SQRT_2, cy * std::f32::consts::FRAC_1_SQRT_2); + vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)] + } + ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)], + ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)], + _ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)], + } +} + +/// Site `site` of a `kind` shape with frame `rect` (x, y, w, h) on the page, turned and flipped by +/// `spin` about its centre. `None` for a site the shape doesn't have. +pub fn site_point(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, site: u32) -> Option<(f32, f32)> { + let [x, y, w, h] = rect; + let (sx, sy) = *sites(kind, w, h).get(site as usize)?; + let (cx, cy) = (x + w / 2.0, y + h / 2.0); + let (px, py) = spin.apply(cx, cy, x + sx, y + sy); + (px.is_finite() && py.is_finite()).then_some((px, py)) +} + +/// The site of a `kind` shape (frame `rect`, `spin`) nearest to page point `at`: its index, point +/// and distance. +pub fn nearest_site(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, at: (f32, f32)) -> Option<(u32, (f32, f32), f32)> { + let n = sites(kind, rect[2], rect[3]).len() as u32; + (0..n) + .filter_map(|i| site_point(kind, rect, spin, i).map(|p| (i, p, ((p.0 - at.0).powi(2) + (p.1 - at.1).powi(2)).sqrt()))) + .min_by(|a, b| a.2.total_cmp(&b.2)) +} + +/// A connector's frame: top-left corner, size and flips. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Frame { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub flip_h: bool, + pub flip_v: bool, +} + +/// The frame of a connector from `start` to `end`. +pub fn route(start: (f32, f32), end: (f32, f32)) -> Frame { + let f = wordcraft_geom::finite; + let (x0, y0, x1, y1) = (f(start.0), f(start.1), f(end.0), f(end.1)); + Frame { x: x0.min(x1), y: y0.min(y1), w: (x1 - x0).abs(), h: (y1 - y0).abs(), flip_h: x1 < x0, flip_v: y1 < y0 } +} + +/// A connector's start and end from its frame (the inverse of [`route`]). +pub fn ends(x: f32, y: f32, w: f32, h: f32, flip_h: bool, flip_v: bool) -> ((f32, f32), (f32, f32)) { + let (xa, xb) = if flip_h { (x + w, x) } else { (x, x + w) }; + let (ya, yb) = if flip_v { (y + h, y) } else { (y, y + h) }; + ((xa, ya), (xb, yb)) +} + +/// The path of a connector `kind` in a `w` × `h` frame at the origin, from its top-left to its +/// bottom-right corner (flips are the frame's turn): straight, an elbow through the middle (out +/// horizontally, across, in horizontally), or an S-curve. `None` for other kinds. +pub fn connector_segs(kind: ShapeKind, w: f32, h: f32) -> Option> { + let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h)); + let mx = w / 2.0; + Some(match kind { + ShapeKind::StraightConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(w, h)], + ShapeKind::ElbowConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(mx, 0.0), PathSeg::Line(mx, h), PathSeg::Line(w, h)], + ShapeKind::CurvedConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Cubic(mx, 0.0, mx, h, w, h)], + _ => return None, + }) +} + +/// The direction (unit vector) a line or connector `kind` arrives at its end (`at_end`) or +/// leaves its start, in its unflipped `w` × `h` frame: where an arrowhead points. +pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32) { + let (dx, dy) = match kind { + ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => { + if w.abs() > 0.01 || h.abs() < 0.01 { + (1.0, 0.0) + } else { + (0.0, 1.0) + } + } + ShapeKind::Line => (w, -h), + _ => (w, h), + }; + let len = (dx * dx + dy * dy).sqrt(); + let (ux, uy) = if len > 1e-6 && len.is_finite() { (dx / len, dy / len) } else { (1.0, 0.0) }; + if at_end { (ux, uy) } else { (-ux, -uy) } +} + +/// Re-route the glued connectors among a group's members (they share the group's coordinate +/// space). Returns whether any moved. +pub fn reroute_group(children: &mut [GroupChild]) -> bool { + let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children + .iter() + .filter_map(|c| match &c.obj { + InlineObject::Shape { kind, w, h, float, extra, .. } if extra.id != 0 => Some((extra.id, *kind, [c.x, c.y, *w, *h], float.spin())), + _ => None, + }) + .collect(); + if shapes.is_empty() { + return false; + } + let find = |e: Option| { + let e = e?; + let (_, kind, rect, spin) = shapes.iter().find(|s| s.0 == e.id)?; + site_point(*kind, *rect, *spin, e.site) + }; + let mut moved = false; + for c in children.iter_mut() { + let InlineObject::Shape { kind, w, h, float, extra, .. } = &mut c.obj else { continue }; + if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) { + continue; + } + let (s0, e0) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + let (s, e) = (find(extra.start).unwrap_or(s0), find(extra.end).unwrap_or(e0)); + let f = route(s, e); + let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01; + if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 { + continue; + } + c.x = f.x; + c.y = f.y; + *w = f.w; + *h = f.h; + float.rot = 0.0; + float.flip_h = f.flip_h; + float.flip_v = f.flip_v; + moved = true; + } + moved +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::para::Float; + + fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject { + InlineObject::Shape { + kind, + w, + h, + fill: None, + stroke: None, + stroke_width: 1.0, + float: Float::default(), + story: None, + freeform: None, + effects: Default::default(), + extra, + } + } + + #[test] + fn route_and_ends_are_inverse() { + for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] { + let f = route(s, e); + assert_eq!(ends(f.x, f.y, f.w, f.h, f.flip_h, f.flip_v), (s, e)); + } + } + + #[test] + fn group_connectors_follow_their_shapes() { + let a = ShapeExtra { id: 7, ..Default::default() }; + let b = ShapeExtra { id: 9, ..Default::default() }; + let link = ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), end: Some(ConnEnd { id: 9, site: 2 }), ..Default::default() }; + let mut kids = vec![ + GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 20.0, 10.0, a) }, + GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) }, + GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) }, + ]; + assert!(reroute_group(&mut kids)); + // From the rectangle's right side (20, 5) to the ellipse's left side (100, 55). + let c = &kids[2]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false)); + assert!(!reroute_group(&mut kids), "settled"); + // A connection to a missing id or site keeps that end where it was. + if let InlineObject::Shape { extra, .. } = &mut kids[2].obj { + extra.end = Some(ConnEnd { id: 404, site: 0 }); + extra.start = Some(ConnEnd { id: 7, site: 99 }); + } + assert!(!reroute_group(&mut kids)); + } +} diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index b781e8c..ee3bb61 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -60,7 +60,7 @@ pub(crate) fn each_para<'a>(b: &'a Block, depth: usize, f: &mut dyn FnMut(&'a Pa } } -fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { +pub(crate) fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { match b { Block::Para(p) => f(p), Block::Table(t) if depth < 16 => { @@ -688,6 +688,7 @@ mod tests { story, freeform: None, effects: Default::default(), + extra: Default::default(), } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1914be2..1d9aea2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod bidi; +pub mod connector; pub mod control; pub mod chart; pub mod edit; @@ -32,6 +33,7 @@ pub mod resolve; pub mod section; pub mod styles; pub mod table; +pub mod wordart; pub mod wrap; use std::collections::BTreeMap; @@ -1029,6 +1031,49 @@ impl Document { out } + /// A drawing id no shape uses yet (for gluing a connector to a shape that has none). + pub fn next_shape_id(&self) -> u32 { + let mut max = 0u32; + let mut see = |o: &InlineObject| match o { + InlineObject::Shape { extra, .. } => max = max.max(extra.id), + InlineObject::Group { children, .. } => { + for c in children { + if let InlineObject::Shape { extra, .. } = &c.obj { + max = max.max(extra.id); + } + } + } + _ => {} + }; + for blocks in std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks)) { + for b in blocks { + edit::each_para(b, 0, &mut |p| p.objects.iter().for_each(&mut see)); + } + } + max.saturating_add(1).max(1) + } + + /// Change every inline object in every story (paragraphs inside tables too), copying only + /// the blocks whose paragraphs `wants` says have objects to change. + pub fn objects_mut(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) { + let mut stories: Vec<&mut Blocks> = vec![&mut self.body]; + stories.extend(self.parts.values_mut().map(|p| &mut p.blocks)); + for blocks in stories { + for b in blocks.iter_mut() { + let mut any = false; + edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants)); + if any { + edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| { + if p.objects.iter().any(wants) { + p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f); + p.touch(); + } + }); + } + } + } + } + /// Number of paragraphs in the body (all depths). pub fn paragraph_count(&self) -> usize { self.para_paths(StoryRef::Body).len() diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 9256c9e..b8ab019 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -294,6 +294,10 @@ pub enum ShapeKind { TextBox, /// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`). Freeform, + /// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]). + StraightConnector, + ElbowConnector, + CurvedConnector, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] @@ -404,6 +408,9 @@ pub enum InlineObject { /// Shadow, glow and soft edges. #[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")] effects: crate::effects::ShapeEffects, + /// Drawing id, text warp, connector ends and arrowheads. + #[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")] + extra: crate::connector::ShapeExtra, }, /// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it /// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index f5407d7..40de99b 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -281,6 +281,10 @@ pub struct CharProps { pub emboss: Option, #[serde(skip_serializing_if = "Option::is_none")] pub engrave: Option, + /// WordArt text effects: fill, outline, shadow, glow, reflection ([MS-DOCX] `w14:` run + /// properties). Overlaid effect by effect. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_effects: Option>, /// BCP 47 language tag (proofing). #[serde(skip_serializing_if = "Option::is_none")] pub lang: Option, @@ -391,6 +395,9 @@ impl CharProps { overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change); + if let Some(p) = &patch.text_effects { + self.text_effects.get_or_insert_with(Default::default).overlay(p); + } if patch.size.is_some() && patch.size_cs.is_none() { self.size_cs = None; } diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 1ec1c21..f2da0cc 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -36,6 +36,9 @@ pub struct ResolvedChar { pub shadow: bool, pub emboss: bool, pub engrave: bool, + /// WordArt text effects (sanitized; `None` when there are none). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_effects: Option>, pub link: Option, pub ins: Option, pub del: Option, @@ -80,6 +83,7 @@ impl ResolvedChar { shadow: c.shadow.unwrap_or(false), emboss: c.emboss.unwrap_or(false), engrave: c.engrave.unwrap_or(false), + text_effects: c.text_effects.as_deref().map(crate::wordart::TextEffects::sanitized).filter(|t| !t.is_empty()).map(Box::new), link: c.link.clone(), ins: c.ins, del: c.del, diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 559ed0b..2041a4e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -90,6 +90,7 @@ fn text_box_anchor_finds_the_owning_shape() { story, freeform: None, effects: Default::default(), + extra: Default::default(), }; // A plain shape before it doesn't count. d.insert_object(&Pos::body(0, 0), shape(None), &CharProps::default()).unwrap(); @@ -112,6 +113,7 @@ fn word_count_including_notes_counts_used_boxes_and_notes_only() { story: Some(story), freeform: None, effects: Default::default(), + extra: Default::default(), }; let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; let boxed = d.add_part(PartKind::TextBox, para_of("three four")); @@ -152,6 +154,7 @@ fn counted_stories_survive_self_nested_boxes() { story: Some(id), freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap(); d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, shape, &CharProps::default()).unwrap(); @@ -171,6 +174,7 @@ fn prune_text_boxes_drops_only_unshown_ones() { story: Some(story), freeform: None, effects: Default::default(), + extra: Default::default(), }; let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; let mut d = Document::from_text("body"); @@ -205,6 +209,7 @@ fn notes_in_text_box_follow_nested_boxes_once() { story: Some(story), freeform: None, effects: Default::default(), + extra: Default::default(), }; let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() }; let mut d = Document::new(); diff --git a/crates/doc/src/wordart.rs b/crates/doc/src/wordart.rs new file mode 100644 index 0000000..ace2674 --- /dev/null +++ b/crates/doc/src/wordart.rs @@ -0,0 +1,372 @@ +//! WordArt: text effects on runs and text warps on shapes. +//! +//! Text effects are the run properties of [MS-DOCX] §2.6 (`w14:textFill`, `w14:textOutline`, +//! `w14:shadow`, `w14:glow`, `w14:reflection`): how a run's letters are filled and outlined and +//! what is drawn around them. A warp ("Transform", DrawingML `a:prstTxWarp`, ECMA-376 +//! §20.1.9.19) bends a shape's whole text along a preset geometry. Lengths are points, angles +//! degrees, transparencies percent (0 = opaque). + +use serde::{Deserialize, Serialize}; + +use crate::effects::{Glow, Shadow}; +use crate::props::Rgb; + +/// Most gradient stops kept. +pub const MAX_STOPS: usize = 10; +/// Widest text outline, points. +pub const MAX_OUTLINE: f32 = 20.0; +/// Largest reflection gap, points. +pub const MAX_REFLECTION_GAP: f32 = 100.0; +/// Most adjust values a warp keeps. +pub const MAX_ADJ: usize = 8; +/// Largest adjust value magnitude (DrawingML angles are 60000ths of a degree, fractions 100000ths). +pub const MAX_ADJ_VALUE: i64 = 100_000_000; + +/// The WordArt effects of a run. `None` fields draw the text normally (its colour, no outline). +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextEffects { + #[serde(skip_serializing_if = "Option::is_none")] + pub fill: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub outline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shadow: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub glow: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reflection: Option, +} + +/// How letters are filled. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum TextFill { + /// Not filled (hollow letters: only their outline shows). + None, + Solid { + color: Rgb, + #[serde(default)] + transparency: f32, + }, + /// A linear gradient across the run's letters, at `angle` degrees (0 = left to right, 90 = top + /// to bottom). + Gradient { + stops: Vec, + #[serde(default)] + angle: f32, + }, +} + +/// A gradient stop at `pos` percent along the gradient. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GradStop { + pub pos: f32, + pub color: Rgb, + #[serde(default)] + pub transparency: f32, +} + +/// The line around letters. `color: None` is explicitly no line. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextOutline { + pub color: Option, + pub width: f32, + pub transparency: f32, +} + +impl Default for TextOutline { + fn default() -> Self { + TextOutline { color: Some(Rgb::BLACK), width: 0.75, transparency: 0.0 } + } +} + +/// A mirror image of the letters below them that fades out: it starts at `transparency` and +/// fades to nothing over `size` percent of the letters' height, `distance` points below them. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Reflection { + pub transparency: f32, + pub size: f32, + pub distance: f32, + pub blur: f32, +} + +impl Default for Reflection { + fn default() -> Self { + Reflection { transparency: 50.0, size: 50.0, distance: 0.0, blur: 0.5 } + } +} + +fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 { + if v.is_finite() { v.clamp(lo, hi) } else { dflt } +} + +impl TextFill { + /// Finite, in range: at most [`MAX_STOPS`] stops, in order; a gradient without stops is no + /// gradient (`None` back). + pub fn sanitized(&self) -> Option { + Some(match self { + TextFill::None => TextFill::None, + TextFill::Solid { color, transparency } => TextFill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) }, + TextFill::Gradient { stops, angle } => { + let mut s: Vec = stops + .iter() + .take(MAX_STOPS) + .map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) }) + .collect(); + if s.is_empty() { + return None; + } + s.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + TextFill::Gradient { stops: s, angle: clamp(*angle, -1e6, 1e6, 0.0).rem_euclid(360.0) } + } + }) + } + /// One colour standing for the fill (a gradient's middle stop): what plain text, the caret + /// and exports without gradients use. + pub fn main_color(&self) -> Option { + match self { + TextFill::None => None, + TextFill::Solid { color, .. } => Some(*color), + TextFill::Gradient { stops, .. } => stops.get(stops.len() / 2).map(|s| s.color), + } + } +} + +impl TextEffects { + pub fn is_empty(&self) -> bool { + *self == TextEffects::default() + } + /// Finite, in range; zero-size glows dropped. + pub fn sanitized(&self) -> TextEffects { + TextEffects { + fill: self.fill.as_ref().and_then(TextFill::sanitized), + outline: self.outline.map(|o| TextOutline { + color: o.color, + width: clamp(o.width, 0.0, MAX_OUTLINE, 0.75), + transparency: clamp(o.transparency, 0.0, 100.0, 0.0), + }), + shadow: self.shadow.map(Shadow::sanitized), + glow: self.glow.map(Glow::sanitized).filter(|g| g.size > 0.0), + reflection: self.reflection.map(|r| Reflection { + transparency: clamp(r.transparency, 0.0, 100.0, 50.0), + size: clamp(r.size, 0.0, 100.0, 50.0), + distance: clamp(r.distance, 0.0, MAX_REFLECTION_GAP, 0.0), + blur: clamp(r.blur, 0.0, crate::effects::MAX_BLUR, 0.0), + }), + } + } + /// Apply every `Some` field of `patch` on top of `self` (style, then direct formatting). + pub fn overlay(&mut self, patch: &TextEffects) { + if patch.fill.is_some() { + self.fill.clone_from(&patch.fill); + } + if patch.outline.is_some() { + self.outline = patch.outline; + } + if patch.shadow.is_some() { + self.shadow = patch.shadow; + } + if patch.glow.is_some() { + self.glow = patch.glow; + } + if patch.reflection.is_some() { + self.reflection = patch.reflection; + } + } +} + +/// Our WordArt styles (Shape Format › WordArt Styles, Insert › WordArt): id and label. Their +/// colours come from the document's theme ([`art_style`]). +pub const ART_STYLES: [(&str, &str); 8] = [ + ("solid", "Solid Accent"), + ("outlined", "Light with Outline"), + ("hollow", "Hollow"), + ("gradient", "Accent Gradient"), + ("shadowed", "Dark with Shadow"), + ("glowing", "Soft Glow"), + ("reflected", "Accent with Reflection"), + ("sunset", "Warm Gradient with Outline"), +]; + +/// The text effects of WordArt style `id` (see [`ART_STYLES`]) in theme colours `theme` (dk1, +/// lt1, dk2, lt2, accent1…6 as in [`crate::THEME_COLORS`]). `None` for an unknown id. +pub fn art_style(id: &str, theme: &[Rgb]) -> Option { + let slot = |i: usize| theme.get(i).or_else(|| crate::THEME_COLORS.get(i)).copied().unwrap_or(Rgb::BLACK); + let (dark, light, accent, accent2) = (slot(2), slot(1), slot(4), slot(5)); + let solid = |c: Rgb| Some(TextFill::Solid { color: c, transparency: 0.0 }); + let line = |c: Rgb, w: f32| Some(TextOutline { color: Some(c), width: w, transparency: 0.0 }); + let grad = |a: Rgb, b: Rgb| { + Some(TextFill::Gradient { + stops: vec![GradStop { pos: 0.0, color: a, transparency: 0.0 }, GradStop { pos: 100.0, color: b, transparency: 0.0 }], + angle: 90.0, + }) + }; + let fx = match id { + "solid" => TextEffects { fill: solid(accent), ..Default::default() }, + "outlined" => TextEffects { fill: solid(light), outline: line(accent, 1.0), ..Default::default() }, + "hollow" => TextEffects { fill: Some(TextFill::None), outline: line(dark, 1.0), ..Default::default() }, + "gradient" => TextEffects { fill: grad(accent, slot(7)), ..Default::default() }, + "shadowed" => TextEffects { + fill: solid(dark), + shadow: Some(Shadow { color: Rgb::BLACK, transparency: 55.0, blur: 3.0, distance: 3.0, angle: 45.0, rot_with_shape: false }), + ..Default::default() + }, + "glowing" => TextEffects { + fill: solid(light), + outline: line(accent, 0.5), + glow: Some(Glow { color: accent, size: 6.0, transparency: 55.0 }), + ..Default::default() + }, + "reflected" => TextEffects { fill: solid(accent), reflection: Some(Reflection::default()), ..Default::default() }, + "sunset" => TextEffects { fill: grad(accent2, slot(8)), outline: line(dark, 0.5), ..Default::default() }, + _ => return None, + }; + Some(fx) +} + +/// Every DrawingML preset text warp (ECMA-376 §20.1.10.76 `ST_TextShapeType`). A warp read with +/// another name is dropped. +pub const WARP_PRESETS: [&str; 41] = [ + "textNoShape", + "textPlain", + "textStop", + "textTriangle", + "textTriangleInverted", + "textChevron", + "textChevronInverted", + "textRingInside", + "textRingOutside", + "textArchUp", + "textArchDown", + "textCircle", + "textButton", + "textArchUpPour", + "textArchDownPour", + "textCirclePour", + "textButtonPour", + "textCurveUp", + "textCurveDown", + "textCanUp", + "textCanDown", + "textWave1", + "textWave2", + "textDoubleWave1", + "textWave4", + "textInflate", + "textDeflate", + "textInflateBottom", + "textDeflateBottom", + "textInflateTop", + "textDeflateTop", + "textDeflateInflate", + "textDeflateInflateDeflate", + "textFadeRight", + "textFadeLeft", + "textFadeUp", + "textFadeDown", + "textSlantUp", + "textSlantDown", + "textCascadeUp", + "textCascadeDown", +]; + +/// The warps WordCraft draws bent (the others round-trip and draw flat), with menu labels. +pub const DRAWN_WARPS: [(&str, &str); 12] = [ + ("textArchUp", "Arch Up"), + ("textArchDown", "Arch Down"), + ("textCircle", "Circle"), + ("textWave1", "Wave"), + ("textSlantUp", "Slant Up"), + ("textSlantDown", "Slant Down"), + ("textInflate", "Inflate"), + ("textDeflate", "Deflate"), + ("textChevron", "Chevron Up"), + ("textChevronInverted", "Chevron Down"), + ("textTriangle", "Triangle Up"), + ("textTriangleInverted", "Triangle Down"), +]; + +/// A shape's text warp: a preset and its adjust values (`a:avLst/a:gd`, name and `val`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextWarp { + pub preset: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub adj: Vec<(String, i64)>, +} + +impl TextWarp { + /// A warp with preset `preset` (one of [`WARP_PRESETS`]); `None` for any other name. + pub fn new(preset: &str) -> Option { + WARP_PRESETS.iter().find(|p| **p == preset).map(|p| TextWarp { preset: (*p).to_string(), adj: Vec::new() }) + } + /// Add an adjust value (capped: at most [`MAX_ADJ`], short ASCII names, values within + /// ±[`MAX_ADJ_VALUE`]); a repeated name replaces the earlier value. + pub fn set_adj(&mut self, name: &str, val: i64) { + if name.is_empty() || name.len() > 16 || !name.chars().all(|c| c.is_ascii_alphanumeric()) { + return; + } + let val = val.clamp(-MAX_ADJ_VALUE, MAX_ADJ_VALUE); + if let Some(slot) = self.adj.iter_mut().find(|(n, _)| n == name) { + slot.1 = val; + } else if self.adj.len() < MAX_ADJ { + self.adj.push((name.to_string(), val)); + } + } + /// The adjust value `name`, if set. + pub fn adj_value(&self, name: &str) -> Option { + self.adj.iter().find(|(n, _)| n == name).map(|(_, v)| *v) + } + /// Valid: a known preset, capped adjust values (what files and commands are reduced to). + pub fn sanitized(&self) -> Option { + let mut w = TextWarp::new(&self.preset)?; + for (n, v) in &self.adj { + w.set_adj(n, *v); + } + Some(w) + } + /// Does the warp leave the text as it is (`textNoShape`, `textPlain`)? + pub fn is_plain(&self) -> bool { + matches!(self.preset.as_str(), "textNoShape" | "textPlain") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostile_values_are_reduced() { + let fx = TextEffects { + fill: Some(TextFill::Gradient { + stops: (0..50).map(|i| GradStop { pos: if i == 0 { f32::NAN } else { 1e9 }, color: Rgb::BLACK, transparency: -5.0 }).collect(), + angle: f32::INFINITY, + }), + outline: Some(TextOutline { color: None, width: 1e9, transparency: f32::NAN }), + ..Default::default() + } + .sanitized(); + let Some(TextFill::Gradient { stops, angle }) = fx.fill else { panic!("a gradient") }; + assert_eq!(stops.len(), MAX_STOPS); + assert!(stops.iter().all(|s| (0.0..=100.0).contains(&s.pos) && s.transparency == 0.0)); + assert_eq!(angle, 0.0); + assert_eq!(fx.outline.map(|o| o.width), Some(MAX_OUTLINE)); + assert_eq!(TextFill::Gradient { stops: vec![], angle: 0.0 }.sanitized(), None); + + assert!(TextWarp::new("textMadeUp").is_none()); + let mut w = TextWarp::new("textArchUp").unwrap(); + for i in 0..20 { + w.set_adj(&format!("adj{i}"), i64::MAX); + } + w.set_adj("bad name!", 1); + assert_eq!(w.adj.len(), MAX_ADJ); + assert!(w.adj.iter().all(|(_, v)| *v == MAX_ADJ_VALUE)); + for (id, _) in ART_STYLES { + assert!(art_style(id, &[]).is_some(), "{id}"); + } + } +} diff --git a/crates/doc/src/wrap.rs b/crates/doc/src/wrap.rs index d4abb74..5b4f5ae 100644 --- a/crates/doc/src/wrap.rs +++ b/crates/doc/src/wrap.rs @@ -66,7 +66,13 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option> let (w, h) = (pos(w), pos(h)); let pts = match kind { // A freeform's paths (ink among them) aren't known from its kind: its frame is its outline. - ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Line | ShapeKind::Freeform => return None, + ShapeKind::Rectangle + | ShapeKind::TextBox + | ShapeKind::Line + | ShapeKind::Freeform + | ShapeKind::StraightConnector + | ShapeKind::ElbowConnector + | ShapeKind::CurvedConnector => return None, ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(), ShapeKind::RoundedRectangle => { // Corner radius as drawn: 16% of the shorter side. diff --git a/crates/docx/src/read/drawing_color.rs b/crates/docx/src/read/drawing_color.rs index 60c73c3..e5a95c5 100644 --- a/crates/docx/src/read/drawing_color.rs +++ b/crates/docx/src/read/drawing_color.rs @@ -77,6 +77,16 @@ fn scheme_slot(name: &str) -> Option { }) } +/// Theme colour `name` (`accent1`, `tx1`…) of `theme`. +pub(crate) fn scheme(name: &str, theme: &[Rgb]) -> Option { + scheme_slot(name).map(|i| slot(theme, i)) +} + +/// `c` with its luminance changed by `f`. +pub(crate) fn lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb { + adjust_lum(c, f) +} + /// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept. fn mix(c: Rgb, to: f64, v: f64) -> Rgb { let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8; diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index e13f31f..9d8c278 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -9,6 +9,7 @@ mod math; mod props; mod sdt; mod story; +mod wordart; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; @@ -61,6 +62,10 @@ pub(crate) struct Reader<'p> { graphics: HashMap<(GraphicKind, String, u32, u32), Arc>, /// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]). graphic_budget: usize, + /// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id + /// was kept while reading (those not glued to are dropped at the end). + pub glued: HashSet, + pub shape_ids: bool, /// The main document part's name. main: String, /// The package's content types, read when first needed. @@ -120,6 +125,8 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { graphic_parts: HashMap::new(), graphics: HashMap::new(), graphic_budget: MAX_GRAPHIC_WORK, + glued: HashSet::new(), + shape_ids: false, main: main.clone(), content_types: None, embedded: EmbeddedManifest::default(), @@ -198,6 +205,9 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { r.finish_moves(); let links = std::mem::take(&mut r.txbx_links); link_text_boxes(&mut r.doc, links); + if r.shape_ids { + keep_glued_ids(&mut r.doc, &r.glued); + } let mut doc = r.doc; doc.ensure_nonempty(); Ok(doc) @@ -299,6 +309,27 @@ fn link_text_boxes(doc: &mut Document, mut links: Vec<(u32, u32, u32)>) { } } +/// Drop the drawing ids read with shapes, except those connectors are glued to (so a shape +/// without connectors reads back as it was written). +fn keep_glued_ids(doc: &mut Document, glued: &HashSet) { + let has_id = |o: &InlineObject| match o { + InlineObject::Shape { extra, .. } => extra.id != 0, + InlineObject::Group { children, .. } => children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { extra, .. } if extra.id != 0)), + _ => false, + }; + let clear = |o: &mut InlineObject| { + if let InlineObject::Shape { extra, .. } = o + && !glued.contains(&extra.id) + { + extra.id = 0; + } + }; + doc.objects_mut(&has_id, &mut |o| match o { + InlineObject::Group { children, .. } => children.iter_mut().for_each(|c| clear(&mut c.obj)), + o => clear(o), + }); +} + /// Word starts a TOC field in the first entry; the engine keeps it at the end of the TOC heading /// (the paragraph holding the field marks where the TOC is). Move it there when one precedes it. fn toc_field_to_heading(body: &mut Blocks) { @@ -538,6 +569,7 @@ impl Reader<'_> { } self.pc.major_font = self.doc.settings.major_font.clone(); self.pc.minor_font = self.doc.settings.minor_font.clone(); + self.pc.theme = self.doc.settings.theme_colors.clone(); } fn read_styles(&mut self, path: &str) -> Result<(), DocxError> { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index d58b111..4200cc5 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -22,6 +22,8 @@ pub struct PropCtx { pub minor_font: String, /// Style id renames (e.g. a localized default paragraph style id → `Normal`). pub alias: HashMap, + /// The theme's colours (for WordArt effects in theme colours). + pub theme: Vec, } impl PropCtx { @@ -114,6 +116,13 @@ impl PropCtx { c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); } + n if n.starts_with("w14:") => { + let fx = c.text_effects.get_or_insert_with(Default::default); + super::wordart::read(k, &self.theme, fx); + if fx.is_empty() { + c.text_effects = None; + } + } _ => {} } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 5ba7244..4cf2ff1 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use wordcraft_doc::connector::{ConnEnd, ShapeExtra}; use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap}; use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind}; @@ -13,8 +14,9 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi use super::Reader; use super::props::{sectpr, tcpr, trpr}; use crate::package::{Rels, rt}; -use crate::units::{int, measure}; +use crate::units::{int, measure, u32_of}; use crate::xml::El; +use wordcraft_doc::wordart::TextWarp; /// Deepest table nesting we build (deeper tables become plain paragraphs). const MAX_TABLE_DEPTH: usize = 24; @@ -881,6 +883,7 @@ impl Reader<'_> { if !alt.is_empty() { obj.set_alt_text(&alt); } + self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id"))); // 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. if let Some(f) = obj.float_mut() { @@ -1039,8 +1042,13 @@ impl Reader<'_> { let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0)); let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") }); let float = Float { alt: meta, ..Float::default() }; - let obj = - if e.name == "pic:pic" { self.read_pic(e, rels, w, h, alt.clone(), float) } else { Some(self.read_wsp(sc, e, rels, w, h, float)) }; + let obj = if e.name == "pic:pic" { + self.read_pic(e, rels, w, h, alt.clone(), float) + } else { + let mut o = self.read_wsp(sc, e, rels, w, h, float); + self.set_shape_id(&mut o, e.child("wps:cNvPr").and_then(|p| p.attr("id"))); + Some(o) + }; let obj = obj.map(|mut o| { o.set_alt_text(&alt); o @@ -1061,7 +1069,10 @@ impl Reader<'_> { "ellipse" => ShapeKind::Ellipse, "triangle" | "rtTriangle" => ShapeKind::Triangle, "diamond" => ShapeKind::Diamond, - "line" | "straightConnector1" => ShapeKind::Line, + "line" => ShapeKind::Line, + "straightConnector1" => ShapeKind::StraightConnector, + "bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector, + "curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector, "rightArrow" | "leftArrow" | "upArrow" | "downArrow" => ShapeKind::Arrow, "star5" | "star4" | "star6" => ShapeKind::Star, "heart" => ShapeKind::Heart, @@ -1118,7 +1129,35 @@ impl Reader<'_> { part.body.anchor = wordcraft_doc::props::valign_from_anchor(a); } } - InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects } + let mut extra = ShapeExtra::default(); + // Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle. + let arrow = |tag: &str| ln.and_then(|l| l.child(tag)).and_then(|e| e.attr("type")).is_some_and(|t| t != "none"); + extra.arrow_start = arrow("a:headEnd"); + extra.arrow_end = arrow("a:tailEnd"); + // A connector's glued ends (ECMA-376 §20.1.2.2.36, §20.1.2.2.13). + if let Some(cn) = wsp.child("wps:cNvCnPr") { + let end = |tag: &str| { + let e = cn.child(tag)?; + let id = e.attr("id").and_then(u32_of).filter(|v| *v != 0)?; + let site = e.attr("idx").and_then(u32_of).unwrap_or(0).min(wordcraft_doc::connector::MAX_SITE); + Some(ConnEnd { id, site }) + }; + extra.start = end("a:stCxn"); + extra.end = end("a:endCxn"); + self.glued.extend(extra.start.iter().chain(extra.end.iter()).map(|e| e.id)); + } + extra.warp = wsp.child("wps:bodyPr").and_then(|b| b.child("a:prstTxWarp")).and_then(text_warp); + InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects, extra } + } + + /// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]). + fn set_shape_id(&mut self, obj: &mut InlineObject, id: Option<&str>) { + if let InlineObject::Shape { extra, .. } = obj + && let Some(id) = id.and_then(u32_of).filter(|v| *v != 0) + { + extra.id = id; + self.shape_ids = true; + } } fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 { @@ -1159,6 +1198,7 @@ impl Reader<'_> { story, freeform: None, effects: Default::default(), + extra: Default::default(), }); } None @@ -1463,6 +1503,19 @@ fn graphic_work(items: &[GraphicItem]) -> usize { /// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26): its outer shadow, glow and soft edges. /// Other effects are dropped. +/// A preset text warp (`a:prstTxWarp`, ECMA-376 §20.1.9.19) and its adjust values +/// (`a:avLst/a:gd` with `fmla="val N"`). Unknown presets are dropped. +fn text_warp(e: &El) -> Option { + let mut w = TextWarp::new(e.attr("prst")?)?; + for gd in e.child("a:avLst").into_iter().flat_map(|l| l.children("a:gd")) { + let (Some(name), Some(f)) = (gd.attr("name"), gd.attr("fmla")) else { continue }; + if let Some(v) = f.trim().strip_prefix("val").and_then(|v| v.trim().parse::().ok()) { + w.set_adj(name, v); + } + } + Some(w) +} + fn effect_list(l: &El) -> ShapeEffects { let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0); let shadow = l.child("a:outerShdw").map(|e| { diff --git a/crates/docx/src/read/wordart.rs b/crates/docx/src/read/wordart.rs new file mode 100644 index 0000000..d8d5588 --- /dev/null +++ b/crates/docx/src/read/wordart.rs @@ -0,0 +1,115 @@ +//! WordArt text effects in `w:rPr` ([MS-DOCX] §2.6.1: `w14:textFill`, `w14:textOutline`, +//! `w14:shadow`, `w14:glow`, `w14:reflection`). Lengths are EMUs, angles 60000ths of a degree, +//! percentages 1000ths of a percent. + +use wordcraft_doc::effects::{Glow, Shadow}; +use wordcraft_doc::props::Rgb; +use wordcraft_doc::wordart::{GradStop, MAX_STOPS, Reflection, TextEffects, TextFill, TextOutline}; + +use crate::units::{int, measure}; +use crate::xml::El; + +/// Read one `w14:` run property into `fx`; false when `e` isn't one. +pub(crate) fn read(e: &El, theme: &[Rgb], fx: &mut TextEffects) -> bool { + match e.name.as_str() { + "w14:textFill" => fx.fill = fill(e, theme), + "w14:textOutline" => { + let (color, transparency) = match e.els().find(|c| matches!(c.name.as_str(), "w14:solidFill" | "w14:gradFill" | "w14:noFill")) { + Some(f) if f.name == "w14:noFill" => (None, 0.0), + Some(f) => match fill(f, theme) { + Some(TextFill::Solid { color, transparency }) => (Some(color), transparency), + Some(t) => (t.main_color(), 0.0), + None => (None, 0.0), + }, + None => (Some(Rgb::BLACK), 0.0), + }; + fx.outline = Some(TextOutline { color, width: emu(e, "w14:w"), transparency }); + } + "w14:shadow" => { + let (color, transparency) = color(e, theme).unwrap_or((Rgb::BLACK, 60.0)); + fx.shadow = Some( + Shadow { + color, + transparency, + blur: emu(e, "w14:blurRad"), + distance: emu(e, "w14:dist"), + angle: angle(e, "w14:dir"), + rot_with_shape: false, + } + .sanitized(), + ); + } + "w14:glow" => { + let (color, transparency) = color(e, theme).unwrap_or((Glow::default().color, 60.0)); + fx.glow = Some(Glow { color, size: emu(e, "w14:rad"), transparency }.sanitized()).filter(|g| g.size > 0.0); + } + "w14:reflection" => { + // Start opacity and the end position of the fade (both 1000ths of a percent). + let pct = |n: &str, d: f32| e.attr(n).and_then(int).map_or(d, |v| v.clamp(0, 100_000) as f32 / 1000.0); + fx.reflection = Some(Reflection { + transparency: 100.0 - pct("w14:stA", 50.0), + size: pct("w14:endPos", 50.0), + distance: emu(e, "w14:dist"), + blur: emu(e, "w14:blurRad"), + }); + } + _ => return false, + } + true +} + +fn emu(e: &El, n: &str) -> f32 { + e.attr(n).and_then(|v| measure(v, 12_700.0)).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 2000.0) +} + +fn angle(e: &El, n: &str) -> f32 { + e.attr(n).and_then(int).map_or(0.0, |v| v.rem_euclid(21_600_000) as f32 / 60_000.0) +} + +/// A fill element (`w14:noFill`, `w14:solidFill`, `w14:gradFill`), or the one inside `e`. +fn fill(e: &El, theme: &[Rgb]) -> Option { + let f = if matches!(e.name.as_str(), "w14:noFill" | "w14:solidFill" | "w14:gradFill") { + e + } else { + e.els().find(|c| matches!(c.name.as_str(), "w14:noFill" | "w14:solidFill" | "w14:gradFill"))? + }; + match f.name.as_str() { + "w14:noFill" => Some(TextFill::None), + "w14:solidFill" => color(f, theme).map(|(color, transparency)| TextFill::Solid { color, transparency }), + _ => { + let stops: Vec = f + .child("w14:gsLst") + .into_iter() + .flat_map(|l| l.children("w14:gs")) + .take(MAX_STOPS) + .filter_map(|gs| { + let (color, transparency) = color(gs, theme)?; + let pos = gs.attr("w14:pos").and_then(int).map_or(0.0, |v| v.clamp(0, 100_000) as f32 / 1000.0); + Some(GradStop { pos, color, transparency }) + }) + .collect(); + let angle = f.child("w14:lin").map_or(90.0, |l| angle(l, "w14:ang")); + TextFill::Gradient { stops, angle }.sanitized() + } + } +} + +/// The colour element inside `e` (`w14:srgbClr`, `w14:schemeClr`) with its modifiers, and its +/// transparency (percent, from `w14:alpha`). +fn color(e: &El, theme: &[Rgb]) -> Option<(Rgb, f32)> { + let c = e.els().find(|c| matches!(c.name.as_str(), "w14:srgbClr" | "w14:schemeClr"))?; + let val = c.attr("w14:val")?; + let mut rgb = if c.name == "w14:srgbClr" { Rgb::parse(val)? } else { super::drawing_color::scheme(val, theme)? }; + let mut transparency = 0.0; + for m in c.els() { + let v = m.attr("w14:val").and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(-10.0, 10.0)); + match m.name.as_str() { + "w14:lumMod" => rgb = super::drawing_color::lum(rgb, |l| l * v), + "w14:lumOff" => rgb = super::drawing_color::lum(rgb, |l| l + v), + // Unlike DrawingML's `a:alpha`, `w14:alpha` is the transparency. + "w14:alpha" => transparency = (v.clamp(0.0, 1.0) * 100.0) as f32, + _ => {} + } + } + Some((rgb, transparency)) +} diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 743050c..6950e6c 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -5,6 +5,7 @@ mod math; mod props; mod sdt; mod story; +mod wordart; use std::collections::{BTreeMap, HashMap}; @@ -72,6 +73,8 @@ pub(crate) struct Writer<'d> { /// Move ranges open in the output: (revision, range `w:id`, destination?). open_moves: Vec<(u32, String, bool)>, docpr: u32, + /// Shapes' own drawing ids written so far (connectors refer to them). + shape_ids: std::collections::HashSet, z: u32, /// Note part id → file note id, in first-reference order. footnotes: Vec, @@ -131,7 +134,9 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { bookmarks: HashMap::new(), next_rev_id: 0, open_moves: Vec::new(), - docpr: 0, + // Fresh drawing ids start past the shapes' own (connectors refer to those). + docpr: doc.next_shape_id().saturating_sub(1), + shape_ids: std::collections::HashSet::new(), z: 251_658_240, footnotes: Vec::new(), endnotes: Vec::new(), @@ -522,10 +527,15 @@ impl Writer<'_> { } fn next_docpr(&mut self) -> String { - self.docpr += 1; + self.docpr = self.docpr.saturating_add(1); self.docpr.to_string() } + /// The drawing id of a shape: its own (once; a copy gets a fresh one), else a fresh one. + fn shape_docpr(&mut self, own: u32) -> String { + if own != 0 && self.shape_ids.insert(own) { own.to_string() } else { self.next_docpr() } + } + fn bookmark_id(&mut self, name: &str) -> String { let n = self.bookmarks.len() as u32; self.bookmarks.entry(name.to_string()).or_insert(n).to_string() diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 71b377a..62e9e7f 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -106,6 +106,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { (None, Some(b)) => w.empty("w:lang", &[("w:bidi", b)]), (None, None) => {} } + if let Some(fx) = &c.text_effects { + super::wordart::rpr_effects(w, fx); + } } /// Does `c` produce any `w:rPr` content (besides a tracked change)? diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 8d243c0..7d757b4 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -574,12 +574,12 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } - InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, .. } => { + InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } => { self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props); w.open("w:drawing", &[]); - let docpr = self.next_docpr(); + let docpr = self.shape_docpr(extra.id); // 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. @@ -660,8 +660,8 @@ impl Writer<'_> { self.pic(w, &c.obj, &file, at, rels); } } - InlineObject::Shape { .. } => { - let id = self.next_docpr(); + InlineObject::Shape { extra, .. } => { + let id = self.shape_docpr(extra.id); self.wsp(w, &c.obj, Some(&id), at, rels, depth); } _ => {} @@ -884,14 +884,28 @@ 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, freeform } = o else { return }; + let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, float, freeform, extra } = o else { return }; let geom = freeform.as_deref().filter(|_| *kind == ShapeKind::Freeform); w.open("wps:wsp", &[]); if let Some(id) = id { let name = shape_name(*kind, freeform.as_deref(), id); nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt); } - if *kind == ShapeKind::TextBox { + if kind.is_connector() { + // A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`). + let ends = [("a:stCxn", extra.start), ("a:endCxn", extra.end)]; + if ends.iter().any(|(_, e)| e.is_some()) { + w.open("wps:cNvCnPr", &[]); + for (tag, e) in ends { + if let Some(e) = e { + w.empty(tag, &[("id", &e.id.to_string()), ("idx", &e.site.to_string())]); + } + } + w.close("wps:cNvCnPr"); + } else { + w.empty("wps:cNvCnPr", &[]); + } + } else if *kind == ShapeKind::TextBox { w.empty("wps:cNvSpPr", &[("txBox", "1")]); } else { w.empty("wps:cNvSpPr", &[]); @@ -905,6 +919,9 @@ impl Writer<'_> { ShapeKind::Triangle => "triangle", ShapeKind::Diamond => "diamond", ShapeKind::Line => "line", + ShapeKind::StraightConnector => "straightConnector1", + ShapeKind::ElbowConnector => "bentConnector3", + ShapeKind::CurvedConnector => "curvedConnector3", ShapeKind::Arrow => "rightArrow", ShapeKind::Star => "star5", ShapeKind::Heart => "heart", @@ -919,7 +936,7 @@ impl Writer<'_> { } let alpha = geom.map_or(1.0, |f| f.alpha); let ink = geom.is_some_and(|f| f.is_ink()); - match fill.filter(|_| !ink) { + match fill.filter(|_| !ink && !kind.is_open()) { Some(c) => { w.open("a:solidFill", &[]); w.empty("a:srgbClr", &[("val", &c.hex())]); @@ -944,6 +961,7 @@ impl Writer<'_> { if ink { w.empty("a:round", &[]); } + arrow_ends(w, extra); w.close("a:ln"); } None => { @@ -984,7 +1002,14 @@ impl Writer<'_> { if body.anchor != wordcraft_doc::props::VAlign::Top { attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor))); } - w.empty("wps:bodyPr", &attrs); + match extra.warp.as_ref().and_then(|t| t.sanitized()) { + Some(warp) => { + w.open("wps:bodyPr", &attrs); + text_warp(w, &warp); + w.close("wps:bodyPr"); + } + None => w.empty("wps:bodyPr", &attrs), + } w.close("wps:wsp"); } @@ -1324,6 +1349,31 @@ fn cust_geom(w: &mut W, f: &wordcraft_doc::freeform::Freeform, sw: f32, sh: f32) } /// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order. +/// A line's arrowheads (`a:headEnd`, `a:tailEnd`, ECMA-376 §20.1.8.38 / §20.1.8.57). +fn arrow_ends(w: &mut W, extra: &wordcraft_doc::connector::ShapeExtra) { + if extra.arrow_start { + w.empty("a:headEnd", &[("type", "triangle")]); + } + if extra.arrow_end { + w.empty("a:tailEnd", &[("type", "triangle")]); + } +} + +/// A preset text warp (`a:prstTxWarp`, ECMA-376 §20.1.9.19) with its adjust values. +fn text_warp(w: &mut W, warp: &wordcraft_doc::wordart::TextWarp) { + w.open("a:prstTxWarp", &[("prst", &warp.preset)]); + if warp.adj.is_empty() { + w.empty("a:avLst", &[]); + } else { + w.open("a:avLst", &[]); + for (name, v) in &warp.adj { + w.empty("a:gd", &[("name", name), ("fmla", &format!("val {v}"))]); + } + w.close("a:avLst"); + } + w.close("a:prstTxWarp"); +} + fn effect_list(w: &mut W, effects: &ShapeEffects) { let fx = effects.sanitized(); if fx.is_empty() { diff --git a/crates/docx/src/write/wordart.rs b/crates/docx/src/write/wordart.rs new file mode 100644 index 0000000..ca0fee0 --- /dev/null +++ b/crates/docx/src/write/wordart.rs @@ -0,0 +1,121 @@ +//! WordArt text effects as `w14:` run properties ([MS-DOCX] §2.6.1), in the schema's order: +//! glow, shadow, reflection, text outline, text fill. Lengths are EMUs, angles 60000ths of a +//! degree, percentages 1000ths of a percent. + +use wordcraft_doc::props::Rgb; +use wordcraft_doc::wordart::{TextEffects, TextFill}; + +use crate::units::{emu, n}; +use crate::xml::W; + +fn pct(v: f32) -> String { + n((wordcraft_geom::finite(v).clamp(0.0, 100.0) * 1000.0).round() as i64) +} + +fn ang(deg: f32) -> String { + n((wordcraft_geom::finite(deg).rem_euclid(360.0) * 60_000.0).round().clamp(0.0, 21_599_999.0) as i64) +} + +fn len(pt: f32) -> String { + emu(wordcraft_geom::finite(pt).clamp(0.0, 2000.0)) +} + +/// `w14:srgbClr` with its transparency (`w14:alpha` is the transparency, not the opacity). +fn color(w: &mut W, c: Rgb, transparency: f32) { + if transparency > 0.0 { + w.open("w14:srgbClr", &[("w14:val", &c.hex())]); + w.empty("w14:alpha", &[("w14:val", &pct(transparency))]); + w.close("w14:srgbClr"); + } else { + w.empty("w14:srgbClr", &[("w14:val", &c.hex())]); + } +} + +fn fill(w: &mut W, f: &TextFill) { + match f { + TextFill::None => w.empty("w14:noFill", &[]), + TextFill::Solid { color: c, transparency } => { + w.open("w14:solidFill", &[]); + color(w, *c, *transparency); + w.close("w14:solidFill"); + } + TextFill::Gradient { stops, angle } => { + w.open("w14:gradFill", &[]); + w.open("w14:gsLst", &[]); + for s in stops { + w.open("w14:gs", &[("w14:pos", &pct(s.pos))]); + color(w, s.color, s.transparency); + w.close("w14:gs"); + } + w.close("w14:gsLst"); + w.empty("w14:lin", &[("w14:ang", &ang(*angle)), ("w14:scaled", "0")]); + w.close("w14:gradFill"); + } + } +} + +/// The effects of `fx` (sanitized first). +pub fn rpr_effects(w: &mut W, fx: &TextEffects) { + let fx = fx.sanitized(); + if let Some(g) = fx.glow { + w.open("w14:glow", &[("w14:rad", &len(g.size))]); + color(w, g.color, g.transparency); + w.close("w14:glow"); + } + if let Some(s) = fx.shadow { + w.open( + "w14:shadow", + &[ + ("w14:blurRad", &len(s.blur)), + ("w14:dist", &len(s.distance)), + ("w14:dir", &ang(s.angle)), + ("w14:sx", "100000"), + ("w14:sy", "100000"), + ("w14:kx", "0"), + ("w14:ky", "0"), + ("w14:algn", "ctr"), + ], + ); + color(w, s.color, s.transparency); + w.close("w14:shadow"); + } + if let Some(r) = fx.reflection { + w.empty( + "w14:reflection", + &[ + ("w14:blurRad", &len(r.blur)), + ("w14:stA", &pct(100.0 - r.transparency)), + ("w14:stPos", "0"), + ("w14:endA", "0"), + ("w14:endPos", &pct(r.size)), + ("w14:dist", &len(r.distance)), + ("w14:dir", "5400000"), + ("w14:fadeDir", "5400000"), + ("w14:sx", "100000"), + ("w14:sy", "-100000"), + ("w14:kx", "0"), + ("w14:ky", "0"), + ("w14:algn", "bl"), + ], + ); + } + if let Some(o) = fx.outline { + w.open("w14:textOutline", &[("w14:w", &len(o.width)), ("w14:cap", "flat"), ("w14:cmpd", "sng"), ("w14:algn", "ctr")]); + match o.color { + Some(c) => { + w.open("w14:solidFill", &[]); + color(w, c, o.transparency); + w.close("w14:solidFill"); + } + None => w.empty("w14:noFill", &[]), + } + w.empty("w14:prstDash", &[("w14:val", "solid")]); + w.empty("w14:round", &[]); + w.close("w14:textOutline"); + } + if let Some(f) = &fx.fill { + w.open("w14:textFill", &[]); + fill(w, f); + w.close("w14:textFill"); + } +} diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 8a558e2..5e089e0 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -79,6 +79,27 @@ fn every_char_prop_round_trips() { shadow: Some(true), emboss: Some(false), engrave: Some(true), + // WordArt effects ([MS-DOCX] w14 run properties). + text_effects: Some(Box::new(wordcraft_doc::wordart::TextEffects { + fill: Some(wordcraft_doc::wordart::TextFill::Gradient { + stops: vec![ + wordcraft_doc::wordart::GradStop { pos: 0.0, color: Rgb(0x15, 0x60, 0x82), transparency: 0.0 }, + wordcraft_doc::wordart::GradStop { pos: 100.0, color: Rgb(0xE9, 0x71, 0x32), transparency: 25.0 }, + ], + angle: 90.0, + }), + outline: Some(wordcraft_doc::wordart::TextOutline { color: Some(Rgb(0x0E, 0x28, 0x41)), width: 1.5, transparency: 0.0 }), + shadow: Some(wordcraft_doc::effects::Shadow { + color: Rgb::BLACK, + transparency: 60.0, + blur: 4.0, + distance: 3.0, + angle: 45.0, + rot_with_shape: false, + }), + glow: Some(wordcraft_doc::effects::Glow { color: Rgb(0x0F, 0x9E, 0xD5), size: 5.0, transparency: 40.0 }), + reflection: Some(wordcraft_doc::wordart::Reflection::default()), + })), lang: Some("fr-FR".into()), no_proof: Some(true), rtl: Some(false), @@ -736,6 +757,7 @@ fn toc_field_skips_nested_stories() { story: Some(story), freeform: None, effects: Default::default(), + extra: Default::default(), } }; let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading"); @@ -859,6 +881,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() { story: Some(story), freeform: None, effects: Default::default(), + extra: Default::default(), }; let star = InlineObject::Shape { kind: ShapeKind::Star, @@ -871,6 +894,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() }; let mut p = Paragraph::with_text("shapes ", CharProps::default()); @@ -942,6 +966,7 @@ fn groups_round_trip() { glow: None, soft_edge: Some(2.5), }, + extra: Default::default(), }; let tb = InlineObject::Shape { kind: ShapeKind::TextBox, @@ -954,6 +979,7 @@ fn groups_round_trip() { story: Some(story), freeform: None, effects: Default::default(), + extra: 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() }; let group = InlineObject::Group { @@ -1015,6 +1041,7 @@ fn drawing_canvas_round_trips() { story: None, effects: Default::default(), freeform: None, + extra: Default::default(), }; let style = CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: Some(Rgb(0x20, 0x40, 0x80)), stroke_width: 1.5 }; let canvas = InlineObject::Group { @@ -1062,6 +1089,7 @@ fn rotated_drawing_canvas_round_trips() { story: None, effects: Default::default(), freeform: None, + extra: Default::default(), }; let canvas = |float: Float| InlineObject::Group { w: 200.0, @@ -1295,6 +1323,7 @@ fn self_showing_text_box_saves_bounded() { story: Some(id), freeform: None, effects: Default::default(), + extra: Default::default(), }; for _ in 0..30 { d.insert_object( @@ -1393,6 +1422,7 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() { story: None, freeform: Some(Arc::new(f)), effects: Default::default(), + extra: Default::default(), }; // The pen stroke was the seventh drawn: Ink Replay's order comes back too. let pen = shape( @@ -1452,6 +1482,7 @@ fn shape_effects_round_trip() { story: None, freeform: None, effects, + extra: Default::default(), }; let mut p = Paragraph::with_text("x", CharProps::default()); p.insert_object(1, shape, &CharProps::default()).unwrap(); @@ -1527,6 +1558,7 @@ fn rotation_and_flips_round_trip() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; let square = Float { wrap: Wrap::Square, ..Default::default() }; let tri = shape(ShapeKind::Triangle, Float { rot: 90.0, flip_h: true, ..square.clone() }); @@ -1583,6 +1615,7 @@ fn rotated_group_and_shadow_rotation_round_trip() { story: None, freeform: None, effects: ShapeEffects { shadow: Some(Shadow { rot_with_shape, ..Default::default() }), ..Default::default() }, + extra: Default::default(), }; let group = InlineObject::Group { w: 120.0, @@ -1632,6 +1665,7 @@ fn wrap_polygons_round_trip() { story: None, effects: Default::default(), freeform: None, + extra: Default::default(), }; // A hand-edited polygon, including points outside the frame. let poly = WrapPolygon::new(true, [(-300, 0), (21_600, 1200), (18_000, 21_900), (0, 21_600)]).map(Arc::new); @@ -1755,6 +1789,7 @@ fn wrap_text_sides_round_trip() { story: None, effects: Default::default(), freeform: None, + extra: Default::default(), }; let sides = [(Wrap::Square, WrapText::Left), (Wrap::Tight, WrapText::Right), (Wrap::Through, WrapText::Largest), (Wrap::Square, WrapText::BothSides)]; @@ -1808,6 +1843,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() { story: None, effects: Default::default(), freeform: None, + extra: Default::default(), }; let described = shape(AltText { text: "Process flow & \"steps\"".into(), ..Default::default() }); let decorative = shape(AltText { decorative: true, ..Default::default() }); @@ -1856,3 +1892,87 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() { let Some(Block::Table(t)) = r.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!() }; assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year"))); } + +/// WordArt's warp (`a:prstTxWarp` with its adjust value) and connectors glued to shapes +/// (`a:stCxn`/`a:endCxn`, with arrowheads) come back as saved; a shape no connector is glued to +/// loses its drawing id, and hostile values are reduced. +#[test] +fn warps_and_glued_connectors_round_trip() { + use wordcraft_doc::connector::{ConnEnd, ShapeExtra}; + use wordcraft_doc::wordart::TextWarp; + let mut d = Document::new(); + let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Bent", CharProps::default()))]); + let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y: 10.0, ..Default::default() }; + let shape = |kind, story, extra| InlineObject::Shape { + kind, + w: 72.0, + h: 36.0, + fill: None, + stroke: Some(Rgb(0, 0, 0)), + stroke_width: 1.0, + float: float(0.0), + story, + freeform: None, + effects: Default::default(), + extra, + }; + let mut warp = TextWarp::new("textArchUp").unwrap(); + warp.set_adj("adj", 9_000_000); + let art = shape(ShapeKind::TextBox, Some(story), ShapeExtra { id: 5, warp: Some(warp), ..Default::default() }); + let target = shape(ShapeKind::Ellipse, None, ShapeExtra { id: 7, ..Default::default() }); + let lonely = shape(ShapeKind::Rectangle, None, ShapeExtra { id: 9, ..Default::default() }); + let link = ShapeExtra { start: Some(ConnEnd { id: 5, site: 3 }), end: Some(ConnEnd { id: 7, site: 2 }), arrow_end: true, ..Default::default() }; + let mut conn = shape(ShapeKind::ElbowConnector, None, link); + if let InlineObject::Shape { float: f, .. } = &mut conn { + *f = Float { flip_v: true, ..float(80.0) }; + } + let mut p = Paragraph::with_text("", CharProps::default()); + for o in [art.clone(), conn.clone(), target.clone(), lonely] { + let end = p.len(); + p.insert_object(end, o, &CharProps::default()).unwrap(); + } + d.body = vec![para_block(p)]; + let r = rt(&d); + let got = ¶s(&r)[0].objects; + // The glued-to shapes keep their ids, the connector its ends, arrowhead and flip. + let extra = |o: &InlineObject| match o { + InlineObject::Shape { extra, .. } => extra.clone(), + _ => panic!("{o:?}"), + }; + assert_eq!(extra(&got[0]), extra(&art)); + assert_eq!(extra(&got[1]), extra(&conn)); + assert!(matches!(&got[1], InlineObject::Shape { kind: ShapeKind::ElbowConnector, float, .. } if float.flip_v)); + assert_eq!(extra(&got[2]), extra(&target)); + assert_eq!(extra(&got[3]).id, 0, "nothing is glued to it"); + + // Hostile: an unknown warp, a huge adjust value, a glue to a missing shape. + let xml = wordcraft_docx::write(&r).unwrap(); + let mut pkg = zip::ZipArchive::new(std::io::Cursor::new(xml)).unwrap(); + let mut doc_xml = String::new(); + std::io::Read::read_to_string(&mut pkg.by_name("word/document.xml").unwrap(), &mut doc_xml).unwrap(); + let doc_xml = doc_xml + .replace("prst=\"textArchUp\"", "prst=\"textNotAWarp\"") + .replace("a:stCxn id=\"5\" idx=\"3\"", "a:stCxn id=\"404\" idx=\"99999999999\""); + let bytes = replace_part(&wordcraft_docx::write(&r).unwrap(), "word/document.xml", doc_xml.as_bytes()); + let h = wordcraft_docx::read(&bytes).unwrap(); + let got = ¶s(&h)[0].objects; + assert_eq!(extra(&got[0]).warp, None); + assert_eq!(extra(&got[1]).start, Some(ConnEnd { id: 404, site: wordcraft_doc::connector::MAX_SITE })); +} + +/// `zip` with part `name` replaced by `data`. +fn replace_part(zip_bytes: &[u8], name: &str, data: &[u8]) -> Vec { + let mut src = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..src.len() { + let mut f = src.by_index(i).unwrap(); + let n = f.name().to_string(); + out.start_file(n.as_str(), zip::write::SimpleFileOptions::default()).unwrap(); + if n == name { + std::io::Write::write_all(&mut out, data).unwrap(); + } else { + std::io::copy(&mut f, &mut out).unwrap(); + } + } + out.finish().unwrap().into_inner() +} diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index 78fb1ac..df18ff7 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -46,6 +46,7 @@ fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject { story: Some(id), effects: Default::default(), freeform: None, + extra: Default::default(), } } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 96d92c7..0e5c00e 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -57,6 +57,7 @@ Home|Font|Subscript|format.subscript Home|Font|Superscript|format.superscript Home|Font|Text Effects Outline|format.outline Home|Font|Text Effects Shadow|format.shadow +Home|Font|Text Effects and Typography|format.textEffects Home|Font|Text Highlight Color|format.highlight Home|Font|Font Color|format.color Home|Font|Character Shading|format.shading @@ -426,6 +427,10 @@ Chart Design|Chart Layouts|Data Labels|chart.dataLabels Shape Format|Shape Styles|Shape Fill|shape.fill Shape Format|Shape Styles|Shape Outline|shape.outline Shape Format|Shape Styles|Shape Effects|shape.effects +Shape Format|WordArt Styles|WordArt Quick Styles|wordArt.style +Shape Format|WordArt Styles|Text Fill|wordArt.textFill +Shape Format|WordArt Styles|Text Outline|wordArt.textOutline +Shape Format|WordArt Styles|Text Effects Transform|wordArt.transform Shape Format|Text|Text Direction|shape.textDirection Shape Format|Text|Align Text|shape.alignText Shape Format|Text|Create Link|shape.link diff --git a/crates/engine/src/cmd/connectors.rs b/crates/engine/src/cmd/connectors.rs new file mode 100644 index 0000000..adadd0a --- /dev/null +++ b/crates/engine/src/cmd/connectors.rs @@ -0,0 +1,292 @@ +//! Connectors: gluing a connector's ends to shapes' connection sites (`shape.connect`), and +//! re-routing glued connectors when the shapes they join move or change size ([`reroute`], run by +//! the session after object edits). +//! +//! Floating connectors in the body are routed on the page (from the layout); connectors inside a +//! group, in the group's own space. A glue to a shape that is gone (or on another page) leaves +//! that end where it is. + +use std::collections::HashMap; + +use serde_json::{Value, json}; +use wordcraft_doc::connector::{ConnEnd, Frame, ends, nearest_site, route, site_point}; +use wordcraft_doc::para::{Anchor, InlineObject, Wrap}; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// How close (points) a dragged end must come to a connection site to glue to it. +pub const SNAP: f32 = 12.0; +/// Largest offset a routed connector gets, points. +const MAX_OFFSET: f32 = 4000.0; + +fn has_connector(s: &Session) -> Option<&'static str> { + match super::objects::selected(s) { + Some((_, InlineObject::Shape { kind, .. })) if kind.is_connector() => None, + _ => Some("select a connector first"), + } +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("shape.connect", "Connect Shapes", "Shape Format › Insert Shapes", connect) + .params( + r#"{"start"?: end, "end"?: end} end: {"path": [..], "off": n, "site"?: n} (glue to that body shape's connection site; nearest one when omitted) | {"x": pt, "y": pt, "page"?: n} (drag the end there: it glues to a shape's site within 12 pt, else stays free there) | null (unglue)"#, + ) + .when(has_connector), + ] +} + +/// Commands after which glued connectors are re-routed: those that move, resize, turn, add or +/// change drawings. +pub fn reroutes_after(id: &str) -> bool { + id.starts_with("arrange.") || id.starts_with("shape.") || id.starts_with("picture.") || id == "insert.shape" || id.starts_with("draw.") +} + +/// The body's floating drawings with an id (by id) and its glued connectors. +fn scan(s: &Session) -> (HashMap, Vec, Vec) { + let (mut ids, mut conns, mut groups) = (HashMap::new(), Vec::new(), Vec::new()); + for path in s.doc.para_paths(StoryRef::Body) { + let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue }; + for off in para.object_offsets() { + let pos = Pos { story: StoryRef::Body, path: path.clone(), off }; + match para.object_at(off) { + Some(InlineObject::Shape { kind, extra, float, .. }) => { + if extra.id != 0 { + ids.entry(extra.id).or_insert(pos.clone()); + } + if kind.is_connector() && float.wrap != Wrap::Inline && (extra.start.is_some() || extra.end.is_some()) { + conns.push(pos); + } + } + Some(InlineObject::Group { children, .. }) + if children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { kind, extra, .. } if kind.is_connector() && (extra.start.is_some() || extra.end.is_some()))) => + { + groups.push(pos) + } + _ => {} + } + } + } + (ids, conns, groups) +} + +/// Re-route every glued connector to the shapes it joins. Returns whether anything moved. +pub fn reroute(s: &mut Session) -> bool { + let (ids, conns, groups) = scan(s); + let mut moved = false; + for pos in groups { + if let Ok(para) = s.doc.para_mut(pos.story, &pos.path) + && let Some(InlineObject::Group { children, .. }) = para.object_at_mut(pos.off) + && wordcraft_doc::connector::reroute_group(children) + { + para.touch(); + moved = true; + } + } + if conns.is_empty() || ids.is_empty() { + return moved; + } + if moved { + s.touch(); + } + let layout = s.layout(); + let site = |e: Option, page: usize| -> Option<(f32, f32)> { + let e = e?; + let target = ids.get(&e.id)?; + let hit = layout.object(target, page)?; + let kind = match s.doc.para_at(target)?.object_at(target.off)? { + InlineObject::Shape { kind, .. } => *kind, + _ => return None, + }; + (hit.page == page).then(|| site_point(kind, [hit.rect.x, hit.rect.y, hit.rect.w, hit.rect.h], hit.spin, e.site))? + }; + let mut plans = Vec::new(); + for pos in conns { + let Some(hit) = layout.object(&pos, s.page_hint) else { continue }; + let Some(InlineObject::Shape { extra, float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue }; + let r = hit.rect; + let (s0, e0) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v); + let to = route(site(extra.start, hit.page).unwrap_or(s0), site(extra.end, hit.page).unwrap_or(e0)); + plans.push((pos, to, (r.x, r.y), (hit.origin.x, hit.origin.y))); + } + for (pos, to, at, origin) in plans { + moved |= place(s, &pos, to, at, origin); + } + moved +} + +/// Put the floating connector at `pos` (now with its frame's top-left at page point `at`, its +/// anchor's origin at `origin`) in frame `to`. Returns whether it changed. +fn place(s: &mut Session, pos: &Pos, to: Frame, at: (f32, f32), origin: (f32, f32)) -> bool { + let Ok(para) = s.doc.para_mut(pos.story, &pos.path) else { return false }; + let Some(InlineObject::Shape { w, h, float, .. }) = para.object_at_mut(pos.off) else { return false }; + let near = |a: f32, b: f32| (a - b).abs() < 0.01; + if near(to.x, at.0) + && near(to.y, at.1) + && near(to.w, *w) + && near(to.h, *h) + && to.flip_h == float.flip_h + && to.flip_v == float.flip_v + && float.rot == 0.0 + { + return false; + } + let clamp = |v: f32| wordcraft_geom::finite(v).clamp(-MAX_OFFSET, MAX_OFFSET); + // An aligned position becomes an offset from the column / paragraph (where `origin` is). + if float.h_align.take().is_some() { + float.h_rel = Anchor::Column; + float.x = clamp(to.x - origin.0); + } else { + float.x = clamp(float.x + to.x - at.0); + } + if float.v_align.take().is_some() { + float.v_rel = Anchor::Paragraph; + float.y = clamp(to.y - origin.1); + } else { + float.y = clamp(float.y + to.y - at.1); + } + *w = to.w.clamp(0.0, MAX_OFFSET); + *h = to.h.clamp(0.0, MAX_OFFSET); + float.rot = 0.0; + float.flip_h = to.flip_h; + float.flip_v = to.flip_v; + para.touch(); + true +} + +/// A body position from `{"path": [..], "off": n}` (or any form `parse_pos` reads). +fn body_pos(e: &Value) -> Option { + let path: Option> = + e.get("path").and_then(Value::as_array).map(|a| a.iter().take(64).filter_map(Value::as_u64).filter_map(|x| u32::try_from(x).ok()).collect()); + match (path, p::u64(e, "off").and_then(|o| usize::try_from(o).ok())) { + (Some(path), Some(off)) if !path.is_empty() => Some(Pos { story: StoryRef::Body, path: wordcraft_doc::Path(path), off }), + _ => super::parse_pos(e).filter(|t| t.story == StoryRef::Body), + } +} + +/// Give the drawing at `pos` a drawing id if it has none; returns it. +fn ensure_id(s: &mut Session, pos: &Pos) -> Result { + let next = s.doc.next_shape_id(); + let para = s.doc.para_mut(pos.story, &pos.path)?; + let (id, fresh) = match para.object_at_mut(pos.off) { + Some(InlineObject::Shape { extra, kind, .. }) if !kind.is_connector() => { + let fresh = extra.id == 0; + if fresh { + extra.id = next; + } + (extra.id, fresh) + } + _ => return Err(CmdError::Params("connectors glue to shapes and text boxes".into())), + }; + if fresh { + para.touch(); + } + Ok(id) +} + +/// `shape.connect`: glue, move or unglue the selected connector's ends. +fn connect(s: &mut Session, v: &Value) -> CmdResult { + let (pos, obj) = super::objects::selected(s).ok_or_else(|| CmdError::Disabled("select a connector first".into()))?; + let InlineObject::Shape { kind, float, .. } = &obj else { return Err(CmdError::Disabled("select a connector first".into())) }; + if !kind.is_connector() { + return Err(CmdError::Disabled("select a connector first".into())); + } + if pos.story != StoryRef::Body || float.wrap == Wrap::Inline { + return Err(CmdError::Disabled("only floating connectors in the body text can be glued".into())); + } + let (fh, fv) = (float.flip_h, float.flip_v); + let layout = s.layout(); + let hit = layout.object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the connector isn't laid out".into()))?; + let r = hit.rect; + let (mut p0, mut p1) = ends(r.x, r.y, r.w, r.h, fh, fv); + let mut glue: [Option>; 2] = [None, None]; + for (i, key) in ["start", "end"].into_iter().enumerate() { + let Some(e) = v.get(key) else { continue }; + if e.is_null() { + glue[i] = Some(None); + continue; + } + let here = if i == 0 { p0 } else { p1 }; + let (target, site) = if e.get("path").is_some() { + let at = body_pos(e).ok_or_else(|| CmdError::Params(format!("`{key}`: no shape there")))?; + let th = layout + .object(&at, hit.page) + .filter(|t| t.page == hit.page) + .ok_or_else(|| CmdError::Params(format!("`{key}`: no shape there on this page")))?; + let tkind = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) { + Some(InlineObject::Shape { kind, .. }) if !kind.is_connector() => *kind, + _ => return Err(CmdError::Params(format!("`{key}`: connectors glue to shapes and text boxes"))), + }; + let rect = [th.rect.x, th.rect.y, th.rect.w, th.rect.h]; + let site = match p::u64(e, "site") { + Some(n) => u32::try_from(n) + .ok() + .filter(|n| site_point(tkind, rect, th.spin, *n).is_some()) + .ok_or_else(|| CmdError::Params(format!("`{key}`: no such connection site")))?, + None => nearest_site(tkind, rect, th.spin, here).map(|(n, ..)| n).unwrap_or(0), + }; + (Some(at), site) + } else { + let (x, y) = (p::f32(e, "x").ok_or_else(|| CmdError::Params(format!("`{key}`: a shape or a point")))?, p::f32(e, "y").unwrap_or(here.1)); + let page = p::u64(e, "page").map_or(hit.page, |n| usize::try_from(n).unwrap_or(usize::MAX)); + if page != hit.page { + return Err(CmdError::Params("a connector's ends stay on its page".into())); + } + // The nearest site of a shape (not a connector) within reach. + let mut best: Option<(Pos, u32, f32)> = None; + if let Some(pg) = layout.pages.get(page) { + for it in &pg.items { + let wordcraft_layout::Placed::Object { rect, spin, story: StoryRef::Body, path, off, .. } = it else { continue }; + let at = Pos { story: StoryRef::Body, path: path.clone(), off: *off }; + if at == pos { + continue; + } + let Some(InlineObject::Shape { kind, .. }) = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) else { continue }; + if kind.is_connector() { + continue; + } + if let Some((n, _, d)) = nearest_site(*kind, [rect.x, rect.y, rect.w, rect.h], *spin, (x, y)) + && d <= SNAP + && best.as_ref().is_none_or(|b| d < b.2) + { + best = Some((at, n, d)); + } + } + } + match best { + Some((at, n, _)) => (Some(at), n), + None => { + // A free end, moved there. + if i == 0 { + p0 = (x, y); + } else { + p1 = (x, y); + } + glue[i] = Some(None); + continue; + } + } + }; + if let Some(at) = target { + let id = ensure_id(s, &at)?; + glue[i] = Some(Some(ConnEnd { id, site })); + } + } + // Free ends first, then glued ones are routed. + let para = s.doc.para_mut(pos.story, &pos.path)?; + if let Some(InlineObject::Shape { extra, .. }) = para.object_at_mut(pos.off) { + if let Some(g) = glue[0] { + extra.start = g; + } + if let Some(g) = glue[1] { + extra.end = g; + } + para.touch(); + } + place(s, &pos, route(p0, p1), (r.x, r.y), (hit.origin.x, hit.origin.y)); + s.touch(); + reroute(s); + let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned(); + Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)})) +} diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index c00724d..7c15c5b 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -458,6 +458,7 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { story: None, freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(tool, fw, fh, local) })), effects: Default::default(), + extra: Default::default(), }; let at = place(s, &layout, obj, page, (fx, fy), first[1])?; // Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step). diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 5d09af8..9d84e5f 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { CommandSpec::new("insert.icon", "Icons", "Insert › Illustrations", icon) .params(r#"{"id"?: string (omit to list the library), "color"?: "RRGGBB", "size"?: pt, "query"?: string, "category"?: string}"#), CommandSpec::new("insert.shape", "Shapes", "Insert › Illustrations", shape) - .params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB"}"#), + .params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart|straightConnector|elbowConnector|curvedConnector", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB", "arrow"?: "none"|"start"|"end"|"both" (lines and connectors; glue a connector's ends with shape.connect)}"#), CommandSpec::new("insert.textBox", "Text Box", "Insert › Text", text_box) .params(r#"{"text"?: string, "width"?: pt, "height"?: pt}"#) .when(|s| { @@ -90,14 +90,6 @@ pub fn specs() -> Vec { p.borders = Some(wordcraft_doc::props::Borders { bottom: Some(wordcraft_doc::props::Border::single(1.5)), ..Default::default() }) }) }), - CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", |s, v| { - let text = p::str(v, "text").unwrap_or("Your text here").to_string(); - let props = CharProps { size: Some(36.0), bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), outline: Some(false), ..Default::default() }; - let at = delete_selection(s)?; - let end = s.doc.insert_text(&at, &text, &props)?; - s.sel = Selection { anchor: at, focus: end }; - sel_result(s) - }), CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| { let path = p::req_str(v, "path")?; let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; @@ -288,25 +280,31 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult { let kind: ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?; let w = p::f32(v, "width").unwrap_or(108.0).clamp(4.0, 2000.0); - let h = p::f32(v, "height").unwrap_or(if kind == ShapeKind::Line { 1.0 } else { 72.0 }).clamp(1.0, 2000.0); + let h = + p::f32(v, "height").unwrap_or(if kind == ShapeKind::Line { 1.0 } else { 72.0 }).clamp(if kind.is_connector() { 0.0 } else { 1.0 }, 2000.0); let fill = match p::str(v, "fill") { Some(c) => Rgb::parse(c), - None if kind == ShapeKind::Line => None, + None if kind.is_open() => None, None => Some(Rgb(0x15, 0x60, 0x82)), }; - let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A))); - let obj = InlineObject::Shape { - kind, - w, - h, - fill, - stroke, - stroke_width: 1.0, - float: Float { wrap: Wrap::Inline, ..Default::default() }, - story: None, - freeform: None, - effects: Default::default(), + // Arrowheads on lines and connectors. + let (arrow_start, arrow_end) = match p::str(v, "arrow") { + Some("end") => (false, true), + Some("start") => (true, false), + Some("both") => (true, true), + Some("none") | None => (false, false), + Some(x) => return Err(CmdError::Params(format!("`arrow`: none, start, end or both, not `{x}`"))), }; + let extra = wordcraft_doc::connector::ShapeExtra { arrow_start, arrow_end, ..Default::default() }; + // Connectors float in front of the text, so they can be glued to floating shapes. + let float = if kind.is_connector() { + Float { wrap: Wrap::InFrontOfText, ..Default::default() } + } else { + Float { wrap: Wrap::Inline, ..Default::default() } + }; + let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A))); + let obj = + InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float, story: None, freeform: None, effects: Default::default(), extra }; // A selected Drawing Canvas takes it in (and stays selected for the next one). if super::canvas::add_member(s, obj.clone())?.is_some() { return sel_result(s); @@ -334,6 +332,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult { story: Some(id), freeform: None, effects: Default::default(), + extra: Default::default(), }; // A selected Drawing Canvas takes it in; otherwise it goes at the caret. if super::canvas::add_member(s, obj.clone())?.is_none() { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8130b30..c161333 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,6 +6,7 @@ pub mod caret; pub mod chart; pub mod citations; pub mod column; +pub mod connectors; pub mod controls; pub mod design; pub mod draw; @@ -36,6 +37,7 @@ pub mod table_style; pub mod text; pub mod tools; pub mod view; +pub mod wordart; use serde_json::{Value, json}; use wordcraft_doc::props::CharProps; @@ -75,6 +77,8 @@ pub fn registry() -> Registry { v.extend(chart::specs()); v.extend(draw::specs()); v.extend(canvas::specs()); + v.extend(wordart::specs()); + v.extend(connectors::specs()); v.extend(tools::specs()); v.extend(speech::specs()); v.extend(controls::specs()); diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs new file mode 100644 index 0000000..fe3883b --- /dev/null +++ b/crates/engine/src/cmd/wordart.rs @@ -0,0 +1,319 @@ +//! WordArt: Insert › WordArt, Home › Font › Text Effects, and Shape Format › WordArt Styles (our +//! own styles in theme colours, text fill, text outline, Transform). +//! +//! Text effects are run properties ([`wordcraft_doc::wordart::TextEffects`]); a Transform is the +//! text warp of a shape ([`wordcraft_doc::wordart::TextWarp`]). With a shape selected, or the +//! caret in a text box, the effects apply to the whole box's text, as WordArt is formatted. + +use serde_json::{Value, json}; +use wordcraft_doc::effects::{Glow, Shadow}; +use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, OBJ, ShapeKind, Wrap}; +use wordcraft_doc::props::{CharProps, Rgb}; +use wordcraft_doc::wordart::{GradStop, Reflection, TextEffects, TextFill, TextOutline, TextWarp, art_style}; +use wordcraft_doc::{Paragraph, PartKind, Pos, StoryRef, para_block}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset", "clear"?: bool, "fill"?: "RRGGBB"|"none" (hollow)|{"color": "RRGGBB", "transparency"?: %}|{"gradient": ["RRGGBB", …], "angle"?: deg}|null (the run's colour), "outline"?: "RRGGBB"|"none"|{"color"?: "RRGGBB"|null, "width"?: pt, "transparency"?: %}|null, "shadow"?: preset|{"color"?, "transparency"?, "blur"?, "distance"?, "angle"?}|null, "glow"?: pt|{"color"?, "size"?, "transparency"?}|null, "reflection"?: bool|{"transparency"?: %, "size"?: %, "distance"?: pt}|null} (an omitted key is left as it is; with a shape selected or the caret in a text box, the box's whole text)"#; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", insert).params( + r#"{"text"?: string, "style"?: WordArt style id (see format.textEffects), "transform"?: "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|… (a DrawingML preset text warp)}"#, + ), + CommandSpec::new("format.textEffects", "Text Effects", "Home › Font", text_effects).params(EFFECT_PARAMS), + CommandSpec::new("wordArt.style", "WordArt Styles", "Shape Format › WordArt Styles", |s, v| { + let id = p::req_str(v, "style")?; + text_effects(s, &json!({"style": id})) + }) + .params(r#"{"style": "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset"}"#), + CommandSpec::new("wordArt.textFill", "Text Fill", "Shape Format › WordArt Styles", |s, v| { + text_effects(s, &json!({"fill": v.get("fill").or_else(|| v.get("color")).cloned().unwrap_or(Value::Null)})) + }) + .params(r#"{"fill": "RRGGBB"|"none"|{"gradient": ["RRGGBB", …], "angle"?: deg}|null}"#), + CommandSpec::new("wordArt.textOutline", "Text Outline", "Shape Format › WordArt Styles", |s, v| { + text_effects(s, &json!({"outline": v.get("outline").cloned().unwrap_or_else(|| v.clone())})) + }) + .params(r#"{"color"?: "RRGGBB"|null, "width"?: pt} or {"outline": "none"|null}"#), + CommandSpec::new("wordArt.transform", "Transform", "Shape Format › WordArt Styles", transform) + .params(r#"{"preset": "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|"textSlantUp"|"textSlantDown"|"textInflate"|"textDeflate"|"textChevron"|"textChevronInverted"|"textTriangle"|"textTriangleInverted"|… (any DrawingML preset text warp)|"none", "adj"?: n (the preset's adjust value)}"#), + ] +} + +/// The theme's colours (dk1, lt1, dk2, lt2, accent1…6, …). +fn theme(s: &Session) -> Vec { + s.doc.settings.theme_colors.clone() +} + +/// The text box story the selection formats as a whole: the selected shape's, or the one the +/// caret is in. +fn box_story(s: &Session) -> Option { + if let Some((_, InlineObject::Shape { story: Some(id), .. })) = super::objects::object_selection(s) { + return Some(*id); + } + match s.sel.focus.story { + StoryRef::Part(id) if s.sel.is_collapsed() && s.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(id), + _ => None, + } +} + +/// Apply `f` to the selection's characters (a text box's whole text: see [`box_story`]). +fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { + if let Some(id) = box_story(s) { + let (a, b) = (s.doc.start_of(StoryRef::Part(id)), s.doc.end_of(StoryRef::Part(id))); + s.doc.format_range(&a, &b, f)?; + return sel_result(s); + } + super::format::apply(s, f) +} + +fn color(v: &Value, k: &str) -> Option { + p::str(v, k).and_then(Rgb::parse) +} + +/// A change to one effect: `None` leaves it, `Some(None)` removes it. +type Change = Option>; + +fn parse_fill(v: Option<&Value>) -> Result, CmdError> { + let bad = || CmdError::Params("`fill`: a colour, \"none\", an object or null".into()); + Ok(match v { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(x)) if x == "none" => Some(Some(TextFill::None)), + Some(Value::String(x)) => Some(Some(TextFill::Solid { color: Rgb::parse(x).ok_or_else(bad)?, transparency: 0.0 })), + Some(o @ Value::Object(_)) => match o.get("gradient").and_then(Value::as_array) { + Some(cols) => { + let cols: Vec = cols.iter().filter_map(Value::as_str).filter_map(Rgb::parse).take(wordcraft_doc::wordart::MAX_STOPS).collect(); + let n = cols.len().max(2) as f32 - 1.0; + let stops = cols.iter().enumerate().map(|(i, c)| GradStop { pos: 100.0 * i as f32 / n, color: *c, transparency: 0.0 }).collect(); + Some(TextFill::Gradient { stops, angle: p::f32(o, "angle").unwrap_or(90.0) }.sanitized()) + } + None => Some(Some(TextFill::Solid { color: color(o, "color").ok_or_else(bad)?, transparency: p::f32(o, "transparency").unwrap_or(0.0) })), + }, + Some(_) => return Err(bad()), + }) +} + +fn parse_outline(v: Option<&Value>) -> Result, CmdError> { + let d = TextOutline::default(); + Ok(match v { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(x)) if x == "none" => Some(Some(TextOutline { color: None, ..d })), + Some(Value::String(x)) => { + Some(Some(TextOutline { color: Some(Rgb::parse(x).ok_or_else(|| CmdError::Params("`outline`: a colour".into()))?), ..d })) + } + Some(o @ Value::Object(_)) => Some(Some(TextOutline { + color: match o.get("color") { + Some(Value::Null) => None, + Some(_) => color(o, "color").or(d.color), + None => d.color, + }, + width: p::f32(o, "width").unwrap_or(d.width), + transparency: p::f32(o, "transparency").unwrap_or(0.0), + })), + Some(_) => return Err(CmdError::Params("`outline`: a colour, \"none\", an object or null".into())), + }) +} + +fn parse_shadow(v: Option<&Value>) -> Result, CmdError> { + let preset = |id: &str| Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`"))); + Ok(match v { + None => None, + Some(Value::Null) => Some(None), + Some(Value::Bool(b)) => Some(b.then(Shadow::default)), + Some(Value::String(id)) if id == "none" => Some(None), + Some(Value::String(id)) => Some(Some(preset(id)?)), + Some(o @ Value::Object(_)) => { + let mut sh = match p::str(o, "preset") { + Some(id) => preset(id)?, + None => Shadow::default(), + }; + sh.color = color(o, "color").unwrap_or(sh.color); + sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency); + sh.blur = p::f32(o, "blur").unwrap_or(sh.blur); + sh.distance = p::f32(o, "distance").unwrap_or(sh.distance); + sh.angle = p::f32(o, "angle").unwrap_or(sh.angle); + Some(Some(sh.sanitized())) + } + Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())), + }) +} + +fn parse_glow(v: Option<&Value>, accent: Rgb) -> Result, CmdError> { + let d = Glow { color: accent, ..Glow::default() }; + Ok(match v { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(x)) if x == "none" => Some(None), + Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..d }.sanitized()).filter(|g| g.size > 0.0)), + Some(o @ Value::Object(_)) => Some( + Some( + Glow { + color: color(o, "color").unwrap_or(d.color), + size: p::f32(o, "size").unwrap_or(d.size), + transparency: p::f32(o, "transparency").unwrap_or(d.transparency), + } + .sanitized(), + ) + .filter(|g| g.size > 0.0), + ), + Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())), + }) +} + +fn parse_reflection(v: Option<&Value>) -> Result, CmdError> { + let d = Reflection::default(); + Ok(match v { + None => None, + Some(Value::Null) => Some(None), + Some(Value::Bool(b)) => Some(b.then_some(d)), + Some(o @ Value::Object(_)) => Some(Some(Reflection { + transparency: p::f32(o, "transparency").unwrap_or(d.transparency), + size: p::f32(o, "size").unwrap_or(d.size), + distance: p::f32(o, "distance").unwrap_or(d.distance), + blur: p::f32(o, "blur").unwrap_or(d.blur), + })), + Some(_) => return Err(CmdError::Params("`reflection`: true, an object or null".into())), + }) +} + +/// `format.textEffects`: set or clear the WordArt effects of the selection. +fn text_effects(s: &mut Session, v: &Value) -> CmdResult { + let theme = theme(s); + let accent = theme.get(4).copied().unwrap_or(Rgb(0x15, 0x60, 0x82)); + let style = match p::str(v, "style") { + Some(id) => Some(art_style(id, &theme).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{id}`")))?), + None => None, + }; + let clear = p::bool(v, "clear").unwrap_or(false); + let fill = parse_fill(v.get("fill"))?; + let outline = parse_outline(v.get("outline"))?; + let shadow = parse_shadow(v.get("shadow"))?; + let glow = parse_glow(v.get("glow"), accent)?; + let reflection = parse_reflection(v.get("reflection"))?; + let f = |c: &mut CharProps| { + let mut fx = if clear { TextEffects::default() } else { c.text_effects.take().map(|b| *b).unwrap_or_default() }; + if let Some(st) = &style { + fx = st.clone(); + } + if let Some(x) = &fill { + fx.fill.clone_from(x); + } + if let Some(x) = outline { + fx.outline = x; + } + if let Some(x) = shadow { + fx.shadow = x; + } + if let Some(x) = glow { + fx.glow = x; + } + if let Some(x) = reflection { + fx.reflection = x; + } + let fx = fx.sanitized(); + c.text_effects = (!fx.is_empty()).then(|| Box::new(fx)); + }; + apply(s, &f) +} + +/// The shape a Transform applies to: the selected one, or the one whose text box the caret is in. +fn warp_target(s: &Session) -> Option { + if let Some((pos, InlineObject::Shape { .. })) = super::objects::object_selection(s) { + return Some(pos); + } + let StoryRef::Part(id) = s.sel.focus.story else { return None }; + let at = s.doc.text_box_anchor(id)?; + Some(Pos { off: at.off.checked_sub(OBJ.len_utf8())?, ..at }) +} + +/// `wordArt.transform`: bend the shape's text along a preset warp, or straighten it. +fn transform(s: &mut Session, v: &Value) -> CmdResult { + let preset = p::req_str(v, "preset")?; + let warp = match preset { + "none" => None, + id => { + let mut w = TextWarp::new(id).ok_or_else(|| CmdError::Params(format!("unknown text warp `{id}`")))?; + if let Some(a) = v.get("adj").and_then(Value::as_f64).filter(|a| a.is_finite()) { + w.set_adj("adj", a.clamp(-1e12, 1e12) as i64); + } + Some(w) + } + }; + let pos = warp_target(s).ok_or_else(|| CmdError::Disabled("select a shape or WordArt first".into()))?; + let para = s.doc.para_mut(pos.story, &pos.path)?; + match para.object_at_mut(pos.off) { + Some(InlineObject::Shape { extra, .. }) => extra.warp = warp.clone(), + _ => return Err(CmdError::Disabled("select a shape or WordArt first".into())), + } + para.touch(); + Ok(json!({"transform": warp.map(|w| w.preset)})) +} + +/// How wide `text` is at `size` points in the document's body font, points. +fn text_width(s: &Session, text: &str, size: f32, bold: bool) -> f32 { + let family = s.doc.styles.default_chr.font.clone().unwrap_or_else(|| s.doc.settings.minor_font.clone()); + let face = wordcraft_fonts::word::resolve(&family, bold, false).face; + let k = size / face.upem.max(1.0) as f32; + wordcraft_fonts::shape(&face, text, &[], |c| c).iter().map(|g| g.x_advance as f32 * k).sum::() +} + +/// `insert.wordArt`: a floating text box without fill or line, its text in a WordArt style (and +/// optionally bent), centred on the column at the caret's paragraph; its text is selected so +/// typing replaces it. +fn insert(s: &mut Session, v: &Value) -> CmdResult { + let text: String = p::str(v, "text").unwrap_or("Your text here").chars().filter(|c| !c.is_control()).take(1000).collect(); + let text = if text.trim().is_empty() { "Your text here".to_string() } else { text }; + let style = p::str(v, "style").unwrap_or("solid"); + let fx = art_style(style, &theme(s)).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{style}`")))?; + let warp = match p::str(v, "transform") { + None | Some("none") => None, + Some(id) => Some(TextWarp::new(id).ok_or_else(|| CmdError::Params(format!("unknown text warp `{id}`")))?), + }; + let size = 36.0; + let props = CharProps { size: Some(size), bold: Some(true), text_effects: Some(Box::new(fx)), ..Default::default() }; + // A box the text fits on one line in (with the box's margins); a bent one is taller. + let col = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); + let w = (text_width(s, &text, size, true) + 24.0).clamp(72.0, col.max(72.0)); + let mut h = size * 1.35 + 7.2; + if let Some(wp) = &warp { + h = match wp.preset.as_str() { + "textCircle" => w, + "textArchUp" | "textArchDown" => w * 0.5, + _ => h * 1.6, + }; + } + let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, props))]); + if let Some(p) = s.doc.parts.get_mut(&id) + && let Some(b) = p.blocks.first_mut() + && let wordcraft_doc::Block::Para(para) = std::sync::Arc::make_mut(b) + { + para.props.align = Some(wordcraft_doc::props::Align::Center); + } + let obj = InlineObject::Shape { + kind: ShapeKind::TextBox, + w, + h: h.clamp(18.0, 2000.0), + fill: None, + stroke: None, + stroke_width: 0.0, + float: Float { + wrap: Wrap::InFrontOfText, + h_rel: Anchor::Margin, + h_align: Some(FloatAlign::Center), + v_rel: Anchor::Paragraph, + ..Default::default() + }, + story: Some(id), + freeform: None, + effects: Default::default(), + extra: wordcraft_doc::connector::ShapeExtra { warp, ..Default::default() }, + }; + let typing = s.typing_props(); + let at = super::delete_selection(s)?; + s.doc.insert_object(&at, obj, &typing)?; + s.sel = Selection { anchor: s.doc.start_of(StoryRef::Part(id)), focus: s.doc.end_of(StoryRef::Part(id)) }; + Ok(json!({"story": id})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e9c6101..4e2d8df 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -164,3 +164,5 @@ mod tests; mod tests_bidi; #[cfg(test)] mod tests_typing; +#[cfg(test)] +mod tests_wordart; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 9b3bc88..55f1b06 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -818,6 +818,10 @@ impl Session { self.touch(); self.doc.ensure_nonempty(); self.doc.prune_text_boxes(); + // Glued connectors follow the shapes they join. + if crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self) { + self.touch(); + } } self.clamp_selection(); if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") { diff --git a/crates/engine/src/tests_wordart.rs b/crates/engine/src/tests_wordart.rs new file mode 100644 index 0000000..9c7b63b --- /dev/null +++ b/crates/engine/src/tests_wordart.rs @@ -0,0 +1,141 @@ +//! WordArt and connectors through the commands. + +use serde_json::json; +use wordcraft_doc::connector::{ConnEnd, ShapeExtra, ends}; +use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Paragraph, Pos, StoryRef, para_block}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A page-placed rectangle (id 1) at x 100 and ellipse (id 2) at x 300, 72 × 36 at y 200, and an +/// elbow connector from the rectangle's right side to the ellipse's left side, all in body +/// paragraph 0 after "Body " (objects at 5, 8, 11). +fn joined() -> Session { + let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() }; + let shape = |kind, x, extra| InlineObject::Shape { + kind, + w: 72.0, + h: 36.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float: float(x), + story: None, + freeform: None, + effects: Default::default(), + extra, + }; + let link = ShapeExtra { start: Some(ConnEnd { id: 1, site: 3 }), end: Some(ConnEnd { id: 2, site: 2 }), ..Default::default() }; + let mut p = Paragraph::with_text("Body ", CharProps::default()); + for o in [ + shape(ShapeKind::Rectangle, 100.0, ShapeExtra { id: 1, ..Default::default() }), + shape(ShapeKind::Ellipse, 300.0, ShapeExtra { id: 2, ..Default::default() }), + shape(ShapeKind::ElbowConnector, 0.0, link), + ] { + let end = p.len(); + p.insert_object(end, o, &CharProps::default()).unwrap(); + } + let mut d = Document::new(); + d.body = vec![para_block(p)]; + Session::new(d) +} + +fn select(s: &mut Session, off: usize) { + run(s, "select.range", json!({"anchor": Pos::body(0, off), "focus": Pos::body(0, off + 3)})); +} + +/// The connector's start and end on the page. +fn connector_ends(s: &mut Session) -> ((f32, f32), (f32, f32)) { + let pos = Pos::body(0, 11); + let hit = s.layout().object(&pos, 0).unwrap(); + let Some(InlineObject::Shape { float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { panic!() }; + let r = hit.rect; + let (a, b) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v); + let round = |p: (f32, f32)| ((p.0 * 100.0).round() / 100.0, (p.1 * 100.0).round() / 100.0); + (round(a), round(b)) +} + +#[test] +fn moving_a_joined_shape_reroutes_its_connector() { + let mut s = joined(); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 30})); + // From the rectangle's right side (172, 248) to the ellipse's left side (300, 218). + assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); + // Resizing the ellipse moves the end with its site. + select(&mut s, 8); + run(&mut s, "arrange.bounds", json!({"width": 100, "height": 60})); + assert_eq!(connector_ends(&mut s).1, (300.0, 230.0)); + // One undo takes the resize and the re-route back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); +} + +#[test] +fn dragging_a_connector_end_glues_it_to_the_nearest_site() { + let mut s = joined(); + select(&mut s, 11); + // Dropped near the ellipse's top: glued there. + run(&mut s, "shape.connect", json!({"end": {"x": 340.0, "y": 195.0}})); + let ext = |s: &Session| match s.doc.para_at(&Pos::body(0, 11)).and_then(|p| p.object_at(11)) { + Some(InlineObject::Shape { extra, .. }) => extra.clone(), + o => panic!("{o:?}"), + }; + assert_eq!(ext(&s).end, Some(ConnEnd { id: 2, site: 0 })); + assert_eq!(connector_ends(&mut s).1, (336.0, 200.0)); + // Dropped in the open: a free end there. + run(&mut s, "shape.connect", json!({"start": {"x": 20.0, "y": 30.0}})); + assert_eq!(ext(&s).start, None); + assert_eq!(connector_ends(&mut s).0, (20.0, 30.0)); + // Glued by reference; a site the shape doesn't have is refused. + run(&mut s, "shape.connect", json!({"start": {"path": [0], "off": 5, "site": 1}})); + assert_eq!(connector_ends(&mut s).0, (100.0, 218.0)); + assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 5, "site": 40}})).is_err()); + assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 11}})).is_err(), "not to itself"); + // A connection to a shape that is gone is ignored. + select(&mut s, 8); + run(&mut s, "text.delete", json!({})); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 1})); +} + +#[test] +fn wordart_is_inserted_styled_and_bent() { + let mut s = Session::new(Document::new()); + let r = run(&mut s, "insert.wordArt", json!({"text": "Hello", "style": "gradient", "transform": "textArchUp"})); + let id = r["story"].as_u64().unwrap() as u32; + assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Hello"); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(matches!(props.text_effects.as_deref().and_then(|f| f.fill.as_ref()), Some(wordcraft_doc::wordart::TextFill::Gradient { .. }))); + // Its text is drawn from outlines, bent. + let layout = s.layout(); + let draws = wordcraft_layout::display::page_display(&s.doc, &layout.pages[0], &Default::default()); + assert!(draws.iter().any(|d| matches!(d, wordcraft_layout::display::Draw::Art { .. }))); + // The PDF draws the gradient and keeps the text. + let pdf = wordcraft_pdf::export(&s.doc, &Default::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF")); + // Effects and transforms on the selected text box's text; hostile values are refused or capped. + run(&mut s, "format.textEffects", json!({"outline": {"color": "C00000", "width": 1e9}, "glow": 5})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + let fx = props.text_effects.unwrap(); + assert_eq!(fx.outline.map(|o| o.width), Some(wordcraft_doc::wordart::MAX_OUTLINE)); + assert!(fx.glow.is_some() && fx.fill.is_some()); + run(&mut s, "wordArt.transform", json!({"preset": "textWave1", "adj": 1e300})); + assert!(s.run("wordArt.transform", &json!({"preset": "textBogus"})).is_err()); + assert!(s.run("format.textEffects", &json!({"style": "word"})).is_err()); + assert!(s.run("format.textEffects", &json!({"glow": "much"})).is_err()); + let warp = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.object_at(0)).and_then(|o| match o { + InlineObject::Shape { extra, .. } => extra.warp.clone(), + _ => None, + }); + assert_eq!( + warp.map(|w| (w.adj_value("adj"), w.preset)).map(|(a, p)| (p, a)), + Some(("textWave1".into(), Some(wordcraft_doc::wordart::MAX_ADJ_VALUE))) + ); + run(&mut s, "wordArt.transform", json!({"preset": "none"})); +} diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index 913b430..afb8f0a 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -13,6 +13,7 @@ wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } wordcraft-proof = { workspace = true } +kurbo = { workspace = true } unicode-bidi = { workspace = true } unicode-linebreak = { workspace = true } unicode-segmentation = { workspace = true } diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 3b78b7f..e2fe80a 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -121,6 +121,15 @@ pub enum Draw { rect: Rect, items: Vec, }, + /// Text drawn from its outlines with WordArt effects (and bent, in a warped text box): + /// `path` in page coordinates, painted as [`crate::wordart::art_layers`] says. `color` is the + /// run's own colour, the fill when the effects set none. + Art { + path: BezPath, + fx: Box, + color: Rgb, + alpha: f32, + }, /// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at /// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark. MarkText { @@ -320,14 +329,26 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec = Vec::new(); + for it in items.iter() { + match it { + Placed::Lines { story: StoryRef::Part(id), .. } if warps.iter().any(|(w, ..)| w == id) => { + // A warped text box's lines are drawn together, bent, where its first one is. + if done.contains(id) { + continue; + } + done.push(*id); + if let Some((_, rect, warp)) = warps.iter().find(|(w, ..)| w == id) { + let lines: Vec<&Placed> = + items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if s == id)).collect(); + warped_text(doc, &lines, *rect, warp, opts, a, &mut out); + } + } + _ => item(doc, it, opts, a, &mut out), + } + } } if opts.markup && (opts.revisions.insertions_deletions || opts.revisions.formatting) && opts.revisions.changed_lines != ChangeBar::None { change_bars(doc, page, &opts.revisions, ba, &mut out); @@ -335,6 +356,69 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec Vec<(u32, Rect, wordcraft_doc::wordart::TextWarp)> { + items + .iter() + .filter_map(|it| { + let Placed::Object { rect, story, path, off, text_box: Some(id), .. } = it else { return None }; + match doc.para(*story, path)?.object_at(*off)? { + InlineObject::Shape { extra, .. } => extra.warp.clone().filter(|w| !w.is_plain()).map(|w| (*id, *rect, w)), + _ => None, + } + }) + .collect() +} + +/// The text of a warped text box at `rect`: its lines' letters as outlines bent by `warp`, the +/// box's text stretched over the frame inside its margins (as WordArt is). Underlines, marks and +/// other decorations are left out; a warp that is drawn flat leaves the text as it is. +fn warped_text( + doc: &Document, + lines: &[&Placed], + rect: Rect, + warp: &wordcraft_doc::wordart::TextWarp, + opts: &DisplayOptions, + alpha: f32, + out: &mut Vec, +) { + let mut raw = Vec::new(); + for it in lines { + item(doc, it, opts, alpha, &mut raw); + } + // The letters: runs with effects as they are, plain runs from their glyphs. + let mut arts: Vec<(BezPath, Box, Rgb, f32)> = Vec::new(); + let mut hidden = Vec::new(); + for d in &raw { + match d { + Draw::Art { path, fx, color, alpha } => arts.push((path.clone(), fx.clone(), *color, *alpha)), + Draw::Glyphs { face, size, glyphs, color, alpha, synth_italic, .. } => { + if *alpha > 0.0 { + arts.push((crate::wordart::glyph_path(face, *size, glyphs, *synth_italic), Box::default(), *color, *alpha)); + } + let mut g = d.clone(); + if let Draw::Glyphs { alpha, .. } = &mut g { + *alpha = 0.0; + } + hidden.push(g); + } + _ => {} + } + } + let src = arts.iter().filter_map(|(p, ..)| crate::wordart::path_box(p)).reduce(|a, b| a.union(b)); + let inset = Rect::new(rect.x + 7.2, rect.y + 3.6, (rect.w - 14.4).max(1.0), (rect.h - 7.2).max(1.0)); + let fitted = src.and_then(|b| crate::wordart::Warp::new(warp, inset, (b.height() / b.width().max(1e-3)) as f32).map(|w| (b, w))); + let Some((src, w)) = fitted else { + out.extend(raw); + return; + }; + for (path, fx, color, alpha) in arts { + out.push(Draw::Art { path: crate::wordart::warp_path(&path, src, &w), fx, color, alpha }); + } + out.extend(hidden); +} + /// The automatic colour of the bars beside changed lines. pub const CHANGE_BAR: Rgb = Rgb(0x50, 0x50, 0x50); @@ -422,9 +506,10 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer 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, freeform, spin } => { + Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin, extra } => { let mut items = Vec::new(); - shape_draws(*rect, *kind, *fill, *stroke, *stroke_width, effects_in(*effects, spin.within(outer)), freeform.as_deref(), opts, &mut items); + let fx = effects_in(*effects, spin.within(outer)); + shape_draws(*rect, *kind, *fill, *stroke, *stroke_width, fx, freeform.as_deref(), extra, opts, &mut items); spun(*spin, *rect, items, out) } Placed::Graphic { rect, graphic, spin, .. } => spun(*spin, *rect, graphic_draws(doc, graphic, *rect, alpha), out), @@ -696,13 +781,20 @@ fn lines( g.0 = gid; } } + // WordArt effects draw the letters from their outlines; the glyphs stay, invisible, + // for the PDF's text. + let art = rc.text_effects.as_ref().filter(|_| ins.is_none() && del.is_none() && !glyphs.is_empty()); + if let Some(fx) = art { + let path = crate::wordart::glyph_path(&st.face, st.size, &glyphs, st.synth_italic); + out.push(Draw::Art { path, fx: fx.clone(), color, alpha }); + } if !glyphs.is_empty() { out.push(Draw::Glyphs { face: st.face, size: st.size, glyphs, color, - alpha, + alpha: if art.is_some() { 0.0 } else { alpha }, synth_bold: st.synth_bold || ins_mark == Some(InsertMark::Bold), synth_italic: st.synth_italic || ins_mark == Some(InsertMark::Italic), text, @@ -1128,9 +1220,14 @@ fn shape_draws( stroke_width: f32, effects: wordcraft_doc::effects::ShapeEffects, freeform: Option<&wordcraft_doc::freeform::Freeform>, + extra: &wordcraft_doc::connector::ShapeExtra, opts: &DisplayOptions, out: &mut Vec, ) { + if kind.is_open() { + line_draws(rect, kind, stroke, stroke_width, effects, extra, out); + return; + } let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else { out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects }); return; @@ -1161,6 +1258,52 @@ fn shape_draws( } } +/// A line or connector at `rect` (its unflipped frame: flips are the frame's turn), with its +/// arrowheads. +fn line_draws( + rect: Rect, + kind: ShapeKind, + stroke: Option, + stroke_width: f32, + effects: wordcraft_doc::effects::ShapeEffects, + extra: &wordcraft_doc::connector::ShapeExtra, + out: &mut Vec, +) { + use wordcraft_doc::connector::{connector_segs, end_direction}; + match connector_segs(kind, rect.w, rect.h) { + Some(segs) => { + let segs = segs + .into_iter() + .map(|s| match s { + PathSeg::Move(x, y) => PathSeg::Move(rect.x + x, rect.y + y), + PathSeg::Line(x, y) => PathSeg::Line(rect.x + x, rect.y + y), + PathSeg::Cubic(a, b, c, d, e, f) => PathSeg::Cubic(rect.x + a, rect.y + b, rect.x + c, rect.y + d, rect.x + e, rect.y + f), + PathSeg::Close => PathSeg::Close, + }) + .collect(); + out.push(Draw::Path { segs, fill: None, stroke, stroke_width }); + } + None => out.push(Draw::Shape { rect, kind, fill: None, stroke, stroke_width, effects }), + } + let Some(color) = stroke else { return }; + // Our line runs from the bottom-left corner to the top-right; connectors from the top-left + // to the bottom-right. + let (start, end) = + if kind == ShapeKind::Line { ((rect.x, rect.bottom()), (rect.right(), rect.y)) } else { ((rect.x, rect.y), (rect.right(), rect.bottom())) }; + let width = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 }; + let len = (width * 3.5).max(7.0); + for (on, tip, at_end) in [(extra.arrow_start, start, false), (extra.arrow_end, end, true)] { + if !on { + continue; + } + let (dx, dy) = end_direction(kind, rect.w, rect.h, at_end); + let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len); + let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45); + let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close]; + out.push(Draw::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 }); + } +} + pub fn seg_path(segs: &[PathSeg]) -> BezPath { let mut p = BezPath::new(); for s in segs { @@ -1260,10 +1403,10 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di 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, freeform, .. } => { + InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => { let effects = effects_in(*effects, own.within(outer)); let mut items = Vec::new(); - shape_draws(rect, *kind, *fill, *stroke, *stroke_width, effects, freeform.as_deref(), opts, &mut items); + shape_draws(rect, *kind, *fill, *stroke, *stroke_width, effects, freeform.as_deref(), extra, opts, &mut items); spun(own, rect, items, out) } InlineObject::Group { .. } => { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3993f18..faa378b 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -17,6 +17,7 @@ pub mod math; pub mod para; mod table; mod textbox; +pub mod wordart; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; @@ -108,6 +109,8 @@ pub enum Placed { freeform: Option>, effects: wordcraft_doc::effects::ShapeEffects, spin: Spin, + /// Arrowheads and text warp (see [`wordcraft_doc::connector::ShapeExtra`]). + extra: wordcraft_doc::connector::ShapeExtra, }, /// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at /// byte `off` of paragraph `path` (its alt text). @@ -1935,7 +1938,7 @@ 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, freeform, .. } => { + InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => { vec![Placed::Shape { rect, kind: *kind, @@ -1945,6 +1948,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR effects: *effects, freeform: freeform.clone(), spin, + extra: extra.clone(), }] } InlineObject::Graphic { graphic, .. } => { @@ -1971,6 +1975,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR effects: Default::default(), freeform: None, spin, + extra: Default::default(), }, Placed::Clip { rect, spin, items: members }, ] diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 71e4417..ff3aca0 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -234,6 +234,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; let obj = wordcraft_doc::para::OBJ.to_string(); let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default()); @@ -1061,6 +1062,7 @@ fn text_wraps_around_square_float() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); let l = lay(&d); @@ -1100,6 +1102,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); let obj = wordcraft_doc::para::OBJ.len_utf8(); @@ -1153,6 +1156,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); let obj = wordcraft_doc::para::OBJ.len_utf8(); @@ -1207,6 +1211,7 @@ fn line_numbers_borders_text_boxes() { story: Some(id), freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap(); let l = lay(&d); @@ -1546,6 +1551,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w story: Some(id), freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(pos, shape, &Default::default()).unwrap(); id @@ -1618,6 +1624,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); let l = lay(&d); @@ -1839,6 +1846,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document { story: Some(story), freeform: None, effects: Default::default(), + extra: Default::default(), }; for (k, id) in ids.iter().enumerate() { let next = *ids.get(k + 1).unwrap_or(id); @@ -1913,6 +1921,7 @@ fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), } } @@ -2062,6 +2071,7 @@ fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject story: None, freeform: None, effects: Default::default(), + extra: Default::default(), } } @@ -2232,6 +2242,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() { story: Some(story), freeform: None, effects: Default::default(), + extra: Default::default(), }; anchor.insert_object(0, tb, &Default::default()).unwrap(); } @@ -2842,6 +2853,7 @@ fn rotated_float_wraps_around_its_rotated_bounds() { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); @@ -2886,6 +2898,7 @@ fn rotated_group_turns_its_members_and_their_shadows() { story: None, freeform: None, effects: ShapeEffects { shadow: Some(Shadow { angle: 0.0, rot_with_shape, ..Default::default() }), ..Default::default() }, + extra: Default::default(), }; let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 100.0, y: 100.0, rot: 90.0, ..Default::default() }; @@ -3045,6 +3058,7 @@ fn wrapped_shape_doc(kind: wordcraft_doc::para::ShapeKind, w: f32, h: f32, float story: None, effects: Default::default(), freeform: None, + extra: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); d @@ -3309,6 +3323,7 @@ fn rotated_canvas_draws_its_clipped_members_inside_the_turn() { story: None, effects: Default::default(), freeform: None, + extra: Default::default(), }; let canvas = |float: Float| InlineObject::Group { w: 200.0, diff --git a/crates/layout/src/tests_textbox.rs b/crates/layout/src/tests_textbox.rs index 80dc012..34c8263 100644 --- a/crates/layout/src/tests_textbox.rs +++ b/crates/layout/src/tests_textbox.rs @@ -26,6 +26,7 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32, body: Tex story: Some(id), effects: Default::default(), freeform: None, + extra: Default::default(), }; let off = d.para(StoryRef::Body, &Path::top(para)).map_or(0, |p| p.len()); d.insert_object(&Pos::body(para, off), shape, &Default::default()).unwrap(); diff --git a/crates/layout/src/wordart.rs b/crates/layout/src/wordart.rs new file mode 100644 index 0000000..0938b9b --- /dev/null +++ b/crates/layout/src/wordart.rs @@ -0,0 +1,335 @@ +//! WordArt drawing: text effects as paint layers, and text warps. +//! +//! Text with effects is drawn from its glyph outlines ([`glyph_path`]) as a [`Draw::Art`] +//! (crate::display::Draw::Art), which [`art_layers`] turns into plain fills and strokes for the +//! raster and PDF back ends: reflection, shadow, glow, fill, outline, in that order. A warped +//! text box's outlines are bent by [`Warp`] before that. Blurs are approximated with +//! [`wordcraft_doc::effects::bands`], as for shapes. + +use kurbo::{Affine, BezPath, PathEl, Point, Shape}; +use wordcraft_doc::props::Rgb; +use wordcraft_doc::wordart::{TextEffects, TextFill, TextWarp}; +use wordcraft_fonts::FaceRef; +use wordcraft_geom::Rect; + +/// How a layer is painted. +#[derive(Clone, Debug, PartialEq)] +pub enum ArtPaint { + Solid(Rgb, f32), + /// A linear gradient from `p0` to `p1` (page points); stops are (offset 0..1, colour, opacity). + Linear { + p0: (f32, f32), + p1: (f32, f32), + stops: Vec<(f32, Rgb, f32)>, + }, +} + +/// One fill (`stroke: None`) or stroke (its width, points) of a path. +#[derive(Clone, Debug)] +pub struct ArtLayer { + pub path: BezPath, + pub paint: ArtPaint, + pub stroke: Option, +} + +/// The outlines of `glyphs` (glyph id, pen x, baseline y in page points) of `face` at `size`, as +/// one path in page coordinates (slanted for synthetic italics). +pub fn glyph_path(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)], synth_italic: bool) -> BezPath { + let db = wordcraft_fonts::FontDb::global(); + let k = f64::from(size) / face.upem.max(1.0); + let skew = if synth_italic { Affine::new([1.0, 0.0, -0.21, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let mut out = BezPath::new(); + for &(gid, x, y) in glyphs { + if !(x.is_finite() && y.is_finite()) { + continue; + } + let m = Affine::translate((f64::from(x), f64::from(y))) * skew * Affine::scale(k); + for el in db.outline(face, gid).elements() { + out.push(m * *el); + } + } + out +} + +fn bbox(path: &BezPath) -> Option { + if path.elements().is_empty() { + return None; + } + let b = path.bounding_box(); + (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) +} + +/// The layers that draw `path` (text outlines, page coordinates) with effects `fx` (sanitized +/// here); `color` is the run's own colour (the fill when `fx` sets none), `alpha` an overall +/// opacity. +pub fn art_layers(path: &BezPath, fx: &TextEffects, color: Rgb, alpha: f32) -> Vec { + let fx = fx.sanitized(); + let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + let Some(b) = bbox(path) else { return Vec::new() }; + let mut out = Vec::new(); + let fill = fx.fill.clone().unwrap_or(TextFill::Solid { color, transparency: 0.0 }); + let fill_paint = |path: &BezPath| -> Option { + match &fill { + TextFill::None => None, + TextFill::Solid { color, transparency } => Some(ArtPaint::Solid(*color, alpha * (1.0 - transparency / 100.0))), + TextFill::Gradient { stops, angle } => { + let b = bbox(path)?; + let (s, c) = (f64::from(*angle).to_radians().sin(), f64::from(*angle).to_radians().cos()); + let d = (b.width() * c.abs() + b.height() * s.abs()) / 2.0; + let ctr = b.center(); + let p0 = ((ctr.x - d * c) as f32, (ctr.y - d * s) as f32); + let p1 = ((ctr.x + d * c) as f32, (ctr.y + d * s) as f32); + Some(ArtPaint::Linear { + p0, + p1, + stops: stops.iter().map(|g| (g.pos / 100.0, g.color, alpha * (1.0 - g.transparency / 100.0))).collect(), + }) + } + } + }; + let outline = fx.outline.and_then(|o| o.color.map(|c| (c, o.width.max(0.25), alpha * (1.0 - o.transparency / 100.0)))); + // A colour standing for the letters (shadows and reflections of hollow text use the outline's). + let ink = fill.main_color().or(outline.map(|o| o.0)).unwrap_or(color); + if let Some(r) = fx.reflection { + // Mirrored about the letters' bottom edge, `distance` below it, fading out downwards. + let gap = f64::from(r.distance); + let m = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 2.0 * b.y1 + gap]); + let mirror = m * path.clone(); + let top = (b.y1 + gap) as f32; + let bottom = top + (b.height() as f32) * (r.size / 100.0).max(0.01); + let x = b.x0 as f32; + let a = alpha * (1.0 - r.transparency / 100.0); + out.push(ArtLayer { + path: mirror, + paint: ArtPaint::Linear { p0: (x, top), p1: (x, bottom), stops: vec![(0.0, ink, a), (1.0, ink, 0.0)] }, + stroke: None, + }); + } + if let Some(sh) = fx.shadow { + let (dx, dy) = sh.offset(); + let moved = Affine::translate((f64::from(dx), f64::from(dy))) * path.clone(); + for (g, a) in wordcraft_doc::effects::bands(0.0, sh.blur / 2.0, sh.opacity() * alpha) { + let paint = ArtPaint::Solid(sh.color, a); + out.push(ArtLayer { path: moved.clone(), paint: paint.clone(), stroke: None }); + if g > 0.05 { + out.push(ArtLayer { path: moved.clone(), paint, stroke: Some(2.0 * g) }); + } + } + } + if let Some(gl) = fx.glow { + for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity() * alpha) { + if g > 0.05 { + out.push(ArtLayer { path: path.clone(), paint: ArtPaint::Solid(gl.color, a), stroke: Some(2.0 * g) }); + } + } + } + if let Some(p) = fill_paint(path) { + out.push(ArtLayer { path: path.clone(), paint: p, stroke: None }); + } + if let Some((c, w, a)) = outline { + out.push(ArtLayer { path: path.clone(), paint: ArtPaint::Solid(c, a), stroke: Some(w) }); + } + out +} + +/// How a warp bends text: two edges (top and bottom, the text stretched between them), or an arc +/// (the text running along an ellipse, its letters standing out or in). +#[derive(Clone, Copy, Debug)] +enum Bend { + /// The top and bottom edges' heights (fractions of the frame, 0 = top) at `u` across. + Edges(fn(f32, f32) -> (f32, f32)), + /// Along an ellipse from angle `start` (radians, counter-clockwise from the right, y up) + /// sweeping `sweep` (negative: clockwise); `outward`: the letters' tops point away from the + /// centre. + Arc { start: f32, sweep: f32, outward: bool }, +} + +/// A warp fitted to a frame: maps the text's box (`u`, `v` in 0..1, `v` = 0 at the top) onto the +/// frame. +#[derive(Clone, Copy, Debug)] +pub struct Warp { + frame: Rect, + bend: Bend, + /// How far the edges bend (a fraction of the frame's height). + k: f32, + /// The text's thickness along an arc, as a fraction of the radius. + thick: f32, +} + +fn slant_up(u: f32, k: f32) -> (f32, f32) { + (k * (1.0 - u), 1.0 - k * u) +} +fn slant_down(u: f32, k: f32) -> (f32, f32) { + (k * u, 1.0 - k * (1.0 - u)) +} +fn inflate(u: f32, k: f32) -> (f32, f32) { + let s = (std::f32::consts::PI * u).sin(); + (k * (1.0 - s), 1.0 - k * (1.0 - s)) +} +fn deflate(u: f32, k: f32) -> (f32, f32) { + let s = (std::f32::consts::PI * u).sin(); + (k * s, 1.0 - k * s) +} +fn wave(u: f32, k: f32) -> (f32, f32) { + let d = k * (2.0 * std::f32::consts::PI * u).sin(); + (k - d, 1.0 - k - d) +} +fn chevron_up(u: f32, k: f32) -> (f32, f32) { + let t = (2.0 * u - 1.0).abs(); + (k * t, 1.0 - k * (1.0 - t)) +} +fn chevron_down(u: f32, k: f32) -> (f32, f32) { + let t = (2.0 * u - 1.0).abs(); + (k * (1.0 - t), 1.0 - k * t) +} +fn triangle_up(u: f32, k: f32) -> (f32, f32) { + (k * (2.0 * u - 1.0).abs(), 1.0) +} +fn triangle_down(u: f32, k: f32) -> (f32, f32) { + (0.0, 1.0 - k * (2.0 * u - 1.0).abs()) +} + +impl Warp { + /// The warp `w` fitted to `frame` for text whose box is `aspect` times as tall as it is + /// wide. `None` for warps drawn flat (plain, and those WordCraft doesn't bend). + pub fn new(w: &TextWarp, frame: Rect, aspect: f32) -> Option { + use std::f32::consts::PI; + if !(frame.w > 0.5 && frame.h > 0.5 && frame.w.is_finite() && frame.h.is_finite()) { + return None; + } + // `adj` as a fraction (100000ths) of the frame's height, within `max`, else `dflt`. + let frac = |dflt: f32, max: f32| w.adj_value("adj").map_or(dflt, |v| (v as f32 / 100_000.0).clamp(0.0, max)); + // `adj` as the angle the arc sweeps (60000ths of a degree), else a half turn. + let sweep = w.adj_value("adj").map_or(PI, |v| (v as f32 / 60_000.0).clamp(10.0, 350.0).to_radians()); + let edges = |f: fn(f32, f32) -> (f32, f32), k: f32| (Bend::Edges(f), k); + let (bend, k) = match w.preset.as_str() { + "textSlantUp" => edges(slant_up, frac(0.4, 0.9)), + "textSlantDown" => edges(slant_down, frac(0.4, 0.9)), + "textInflate" => edges(inflate, frac(0.3, 0.45)), + "textDeflate" => edges(deflate, frac(0.3, 0.45)), + "textWave1" => edges(wave, frac(0.125, 0.3)), + "textChevron" => edges(chevron_up, frac(0.4, 0.9)), + "textChevronInverted" => edges(chevron_down, frac(0.4, 0.9)), + "textTriangle" => edges(triangle_up, frac(0.5, 0.9)), + "textTriangleInverted" => edges(triangle_down, frac(0.5, 0.9)), + "textArchUp" => (Bend::Arc { start: PI / 2.0 + sweep / 2.0, sweep: -sweep, outward: true }, 0.0), + "textArchDown" => (Bend::Arc { start: 1.5 * PI - sweep / 2.0, sweep, outward: false }, 0.0), + "textCircle" => (Bend::Arc { start: 1.5 * PI - 0.02, sweep: -(2.0 * PI - 0.04), outward: true }, 0.0), + _ => return None, + }; + // Along an arc the text keeps its proportions: as thick as its length along the mean + // radius makes it, within reason. + let thick = match bend { + Bend::Arc { sweep, .. } => { + let aspect = if aspect.is_finite() { aspect.clamp(0.0, 10.0) } else { 0.2 }; + (aspect * sweep.abs()).clamp(0.08, 0.6) + } + Bend::Edges(_) => 0.0, + }; + Some(Warp { frame, bend, k, thick }) + } + + /// The page point for text box point (`u`, `v`). + pub fn map(&self, u: f32, v: f32) -> (f32, f32) { + let (u, v) = (if u.is_finite() { u } else { 0.0 }, if v.is_finite() { v } else { 0.0 }); + let f = self.frame; + match self.bend { + Bend::Edges(edge) => { + let (top, bottom) = edge(u.clamp(-1.0, 2.0), self.k); + (f.x + u * f.w, f.y + (top + (bottom - top) * v) * f.h) + } + Bend::Arc { start, sweep, outward } => { + let (rx, ry) = (f.w / 2.0, f.h / 2.0); + let (cx, cy) = (f.x + rx, f.y + ry); + let a = start + sweep * u; + let rho = if outward { 1.0 - v * self.thick } else { 1.0 - self.thick + v * self.thick }; + (cx + rx * rho * a.cos(), cy - ry * rho * a.sin()) + } + } + } +} + +/// `path` bent by `warp`, its box `src` mapped onto the warp's frame. Curves are flattened and +/// long lines cut short so they bend smoothly. +pub fn warp_path(path: &BezPath, src: kurbo::Rect, warp: &Warp) -> BezPath { + let (w, h) = (src.width().max(1e-3), src.height().max(1e-3)); + let step = (w / 96.0).max(0.25); + let to = |p: Point| { + let (x, y) = warp.map(((p.x - src.x0) / w) as f32, ((p.y - src.y0) / h) as f32); + Point::new(f64::from(x), f64::from(y)) + }; + let mut out = BezPath::new(); + let mut last: Option = None; + // Bounded: a hostile path can't make it endless (each line cut in at most 256 pieces). + kurbo::flatten(path.elements().iter().copied(), 0.05, |el| match el { + PathEl::MoveTo(p) => { + out.move_to(to(p)); + last = Some(p); + } + PathEl::LineTo(p) => { + if let Some(a) = last { + let n = ((a.distance(p) / step).ceil() as usize).clamp(1, 256); + for i in 1..=n { + out.line_to(to(a.lerp(p, i as f64 / n as f64))); + } + } else { + out.move_to(to(p)); + } + last = Some(p); + } + PathEl::ClosePath => out.close_path(), + _ => {} + }); + out +} + +/// The box of `path`, if it has one. +pub fn path_box(path: &BezPath) -> Option { + bbox(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arch_baselines_lie_on_the_arc() { + let frame = Rect::new(10.0, 20.0, 200.0, 100.0); + let w = Warp::new(&TextWarp::new("textArchUp").unwrap(), frame, 0.25).unwrap(); + let (cx, cy, rx, ry) = (110.0, 70.0, 100.0, 50.0); + // A baseline (fixed v) maps onto one ellipse around the frame's centre, over the top. + for v in [0.0, 0.8] { + let rho: Vec = (0..=10) + .map(|i| { + let (x, y) = w.map(i as f32 / 10.0, v); + assert!(y <= cy + 1e-3, "over the top: {y}"); + (((x - cx) / rx).powi(2) + ((y - cy) / ry).powi(2)).sqrt() + }) + .collect(); + assert!(rho.iter().all(|r| (r - rho[0]).abs() < 1e-3), "{rho:?}"); + } + // Left to right, the middle at the top. + let (x0, _) = w.map(0.0, 0.0); + let (xm, ym) = w.map(0.5, 0.0); + let (x1, _) = w.map(1.0, 0.0); + assert!(x0 < xm && xm < x1 && (ym - 20.0).abs() < 1e-3); + // Arch down runs along the bottom. + let d = Warp::new(&TextWarp::new("textArchDown").unwrap(), frame, 0.25).unwrap(); + assert!(d.map(0.5, 1.0).1 > 119.0); + } + + #[test] + fn flat_and_unknown_warps_draw_flat() { + let frame = Rect::new(0.0, 0.0, 100.0, 50.0); + assert!(Warp::new(&TextWarp::new("textPlain").unwrap(), frame, 0.2).is_none()); + assert!(Warp::new(&TextWarp::new("textCanUp").unwrap(), frame, 0.2).is_none()); + // Hostile adjust values stay within the frame. + let mut t = TextWarp::new("textSlantUp").unwrap(); + t.set_adj("adj", i64::MAX); + let w = Warp::new(&t, frame, f32::NAN).unwrap(); + for (u, v) in [(0.0, 0.0), (1.0, 1.0), (f32::NAN, 0.5)] { + let (x, y) = w.map(u, v); + assert!((0.0..=100.0).contains(&x) && (0.0..=50.0).contains(&y), "{x} {y}"); + } + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 28eee4d..0a9510d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -47,6 +47,7 @@ use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display}; +use wordcraft_layout::wordart::ArtPaint; use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, Page, Placed, layout}; use wordcraft_metafile::{Picture, PlacedItem}; @@ -412,6 +413,11 @@ fn shape_path(kind: ShapeKind, r: &Rect) -> Option { pb.move_to(x0, y1); pb.line_to(x1, y0); } + // Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal. + ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => { + pb.move_to(x0, y0); + pb.line_to(x1, y1); + } ShapeKind::Arrow => { let h = r.h; poly( @@ -818,6 +824,56 @@ impl Exporter<'_> { s.set_fill(None); s.set_stroke(None); } + Draw::Art { path, fx, color, alpha } => { + for l in wordcraft_layout::wordart::art_layers(path, fx, *color, *alpha) { + let mut pb = PathBuilder::new(); + append_path(&mut pb, &l.path); + let Some(p) = pb.finish() else { continue }; + let (paint, opacity): (krilla::paint::Paint, f32) = match &l.paint { + ArtPaint::Solid(c, a) => (rgb::Color::new(c.0, c.1, c.2).into(), *a), + ArtPaint::Linear { p0, p1, stops } => { + let stops = stops + .iter() + .map(|(o, c, a)| krilla::paint::Stop { + offset: norm(*o), + color: rgb::Color::new(c.0, c.1, c.2).into(), + opacity: norm(*a), + }) + .collect(); + let g = krilla::paint::LinearGradient { + x1: p0.0, + y1: p0.1, + x2: p1.0, + y2: p1.1, + transform: Transform::default(), + spread_method: krilla::paint::SpreadMethod::Pad, + stops, + anti_alias: true, + }; + (g.into(), 1.0) + } + }; + match l.stroke { + Some(w) => { + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint, + width: w.clamp(0.1, 400.0), + opacity: norm(opacity), + line_join: LineJoin::Round, + ..Default::default() + })); + } + None => { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule: FillRule::NonZero })); + } + } + s.draw_path(&p); + } + s.set_fill(None); + s.set_stroke(None); + } Draw::Mark { .. } | Draw::MarkText { .. } => {} Draw::Clip { rect, items } => { let Some(clip) = rect_path(rect) else { return }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a2d4641..f5c79af 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ use wordcraft_doc::props::Rgb; use wordcraft_fonts::FontDb; use wordcraft_layout::Page; use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display}; +use wordcraft_layout::wordart::ArtPaint; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg}; pub mod icon_lib; @@ -523,6 +524,10 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl line(ctx, 0.0); } Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, .. } => { + // Invisible glyphs (WordArt letters are drawn from their outlines) carry text only. + if *alpha <= 0.0 { + return; + } let db = FontDb::global(); let k = *size as f64 / face.upem.max(1.0); ctx.set_paint(color(opts.ink(*c), *alpha)); @@ -650,6 +655,32 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.stroke_path(&path); } } + Draw::Art { path, fx, color: c, alpha } => { + let Some(b) = wordcraft_layout::wordart::path_box(path) else { return }; + if !b.inflate(200.0, 200.0).overlaps(*visible) { + return; + } + ctx.set_transform(view); + for l in wordcraft_layout::wordart::art_layers(path, fx, *c, *alpha) { + match &l.paint { + ArtPaint::Solid(c, a) => ctx.set_paint(color(opts.ink(*c), *a)), + ArtPaint::Linear { p0, p1, stops } => { + let stops: Vec = + stops.iter().map(|(o, c, a)| peniko::ColorStop::from((o.clamp(0.0, 1.0), color(opts.ink(*c), *a)))).collect(); + let g = peniko::Gradient::new_linear((f64::from(p0.0), f64::from(p0.1)), (f64::from(p1.0), f64::from(p1.1))) + .with_stops(stops.as_slice()); + ctx.set_paint(g); + } + } + match l.stroke { + Some(w) => { + ctx.set_stroke(kurbo::Stroke::new(f64::from(w.clamp(0.1, 400.0))).with_join(kurbo::Join::Round)); + ctx.stroke_path(&l.path); + } + None => ctx.fill_path(&l.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) { @@ -799,6 +830,13 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath { p.line_to((r.x1, r.y0)); p } + // Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal. + ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => { + let mut p = BezPath::new(); + p.move_to((r.x0, r.y0)); + p.line_to((r.x1, r.y1)); + p + } ShapeKind::Arrow => { let h = r.height(); poly(&[ @@ -902,6 +940,7 @@ mod tests { story: None, freeform: None, effects, + extra: Default::default(), }; let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }; d.insert_object(&at, shape, &Default::default()).unwrap(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d14f26..06b9087 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -284,8 +284,8 @@ 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, freeform, spin } => { - format!("{rect:?}{kind:?}{fill:?}{stroke:?}{stroke_width}{effects:?}{spin:?}").hash(&mut h); + Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin, extra } => { + format!("{rect:?}{kind:?}{fill:?}{stroke:?}{stroke_width}{effects:?}{spin:?}{extra:?}").hash(&mut h); freeform.as_ref().map(|f| std::sync::Arc::as_ptr(f) as usize).hash(&mut h); } Placed::Graphic { rect, graphic, spin, .. } => { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1b92822..bc3eef3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1656,6 +1656,42 @@ Rewind Rebobinar Forward Avanzar Stroke {n} of {total} Trazo {n} de {total} + +# WordArt and connectors (#416). + WordArt Styles Estilos de WordArt + Text Fill Relleno de texto + Text Outline Contorno de texto + Transform Transformar + No Fill Sin relleno + No Outline Sin contorno + Reflection Reflejo + No Reflection Sin reflejo + No Transform Sin transformación + Connect Shapes Conectar formas + Straight Connector Conector recto + Straight Arrow Connector Conector recto de flecha + Elbow Connector Conector angular + Elbow Arrow Connector Conector angular de flecha + Curved Connector Conector curvo + Curved Arrow Connector Conector curvo de flecha + Arch Up Arco hacia arriba + Arch Down Arco hacia abajo + Slant Up Inclinación hacia arriba + Slant Down Inclinación hacia abajo + Inflate Inflar + Deflate Desinflar + Chevron Up Chevrón hacia arriba + Chevron Down Chevrón hacia abajo + Triangle Up Triángulo hacia arriba + Triangle Down Triángulo hacia abajo + Solid Accent Color de énfasis sólido + Light with Outline Claro con contorno + Hollow Hueco + Accent Gradient Degradado de énfasis + Dark with Shadow Oscuro con sombra + Soft Glow Resplandor suave + Accent with Reflection Énfasis con reflejo + Warm Gradient with Outline Degradado cálido con contorno Add line numbering Agregar numeración de líneas Add to Document Agregar al documento Address labels Etiquetas de dirección diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 7436997..b143518 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1619,6 +1619,42 @@ Rewind Keri algusesse Forward Edasi Stroke {n} of {total} Joon {n}/{total} + +# WordArt and connectors (#416). + WordArt Styles WordArti laadid + Text Fill Teksti täide + Text Outline Teksti kontuur + Transform Teisenda + No Fill Täiteta + No Outline Kontuurita + Reflection Peegeldus + No Reflection Peegelduseta + No Transform Teisenduseta + Connect Shapes Ühenda kujundid + Straight Connector Sirge ühendusjoon + Straight Arrow Connector Sirge noolega ühendusjoon + Elbow Connector Nurgeline ühendusjoon + Elbow Arrow Connector Nurgeline noolega ühendusjoon + Curved Connector Kaardus ühendusjoon + Curved Arrow Connector Kaardus noolega ühendusjoon + Arch Up Kaar üles + Arch Down Kaar alla + Slant Up Kalle üles + Slant Down Kalle alla + Inflate Paisutus + Deflate Kokkutõmme + Chevron Up Sparre üles + Chevron Down Sparre alla + Triangle Up Kolmnurk üles + Triangle Down Kolmnurk alla + Solid Accent Ühtlane rõhuvärv + Light with Outline Hele kontuuriga + Hollow Õõnes + Accent Gradient Rõhuvärvi üleminek + Dark with Shadow Tume varjuga + Soft Glow Pehme kuma + Accent with Reflection Rõhuvärv peegeldusega + Warm Gradient with Outline Soe üleminek kontuuriga Developer Arendaja Controls Juhtelemendid Rich Text Content Control Vormindatud teksti sisujuhtelement diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ceb25f..96a628a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1640,6 +1640,42 @@ Rewind 巻き戻し Forward 早送り Stroke {n} of {total} ストローク {n}/{total} + +# WordArt and connectors (#416). + WordArt Styles ワードアートのスタイル + Text Fill 文字の塗りつぶし + Text Outline 文字の輪郭 + Transform 変形 + No Fill 塗りつぶしなし + No Outline 輪郭なし + Reflection 反射 + No Reflection 反射なし + No Transform 変形なし + Connect Shapes 図形をつなぐ + Straight Connector 直線コネクタ + Straight Arrow Connector 直線矢印コネクタ + Elbow Connector カギ線コネクタ + Elbow Arrow Connector カギ線矢印コネクタ + Curved Connector 曲線コネクタ + Curved Arrow Connector 曲線矢印コネクタ + Arch Up 上向きアーチ + Arch Down 下向きアーチ + Slant Up 右上がり + Slant Down 右下がり + Inflate 膨張 + Deflate 収縮 + Chevron Up 上向き山形 + Chevron Down 下向き山形 + Triangle Up 上向き三角形 + Triangle Down 下向き三角形 + Solid Accent アクセントの単色 + Light with Outline 明るい色と輪郭 + Hollow 中抜き + Accent Gradient アクセントのグラデーション + Dark with Shadow 暗い色と影 + Soft Glow 柔らかい光彩 + Accent with Reflection アクセントと反射 + Warm Gradient with Outline 暖色のグラデーションと輪郭 Add line numbering 行番号を追加する Add to Document 文書に追加 Address labels 宛名ラベル diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 275b7ce..f8c8062 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1652,6 +1652,42 @@ Rewind Retroceder Forward Avançar Stroke {n} of {total} Traço {n} de {total} + +# WordArt and connectors (#416). + WordArt Styles Estilos de WordArt + Text Fill Preenchimento do texto + Text Outline Contorno do texto + Transform Transformar + No Fill Sem preenchimento + No Outline Sem contorno + Reflection Reflexo + No Reflection Sem reflexo + No Transform Sem transformação + Connect Shapes Conectar formas + Straight Connector Conector reto + Straight Arrow Connector Conector reto com seta + Elbow Connector Conector angular + Elbow Arrow Connector Conector angular com seta + Curved Connector Conector curvo + Curved Arrow Connector Conector curvo com seta + Arch Up Arco para cima + Arch Down Arco para baixo + Slant Up Inclinação para cima + Slant Down Inclinação para baixo + Inflate Inflar + Deflate Desinflar + Chevron Up Divisa para cima + Chevron Down Divisa para baixo + Triangle Up Triângulo para cima + Triangle Down Triângulo para baixo + Solid Accent Destaque sólido + Light with Outline Claro com contorno + Hollow Vazado + Accent Gradient Gradiente de destaque + Dark with Shadow Escuro com sombra + Soft Glow Brilho suave + Accent with Reflection Destaque com reflexo + Warm Gradient with Outline Gradiente quente com contorno Add line numbering Adicionar numeração de linhas Add to Document Adicionar ao documento Address labels Etiquetas de endereço diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a858bf2..761a9b7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2027,6 +2027,42 @@ preview Title Пример Continuous numbering Сквозная нумерация Apply Применить Convert Notes Преобразовать сноски + +# WordArt and connectors (#416). + WordArt Styles Стили WordArt + Text Fill Заливка текста + Text Outline Контур текста + Transform Трансформация + No Fill Нет заливки + No Outline Нет контура + Reflection Отражение + No Reflection Без отражения + No Transform Без трансформации + Connect Shapes Соединить фигуры + Straight Connector Прямая соединительная линия + Straight Arrow Connector Прямая соединительная линия со стрелкой + Elbow Connector Уголковая соединительная линия + Elbow Arrow Connector Уголковая линия со стрелкой + Curved Connector Кривая соединительная линия + Curved Arrow Connector Кривая линия со стрелкой + Arch Up Арка вверх + Arch Down Арка вниз + Slant Up Наклон вверх + Slant Down Наклон вниз + Inflate Раздувание + Deflate Сжатие + Chevron Up Шеврон вверх + Chevron Down Шеврон вниз + Triangle Up Треугольник вверх + Triangle Down Треугольник вниз + Solid Accent Сплошной акцент + Light with Outline Светлый с контуром + Hollow Полый + Accent Gradient Градиент акцента + Dark with Shadow Тёмный с тенью + Soft Glow Мягкое свечение + Accent with Reflection Акцент с отражением + Warm Gradient with Outline Тёплый градиент с контуром Add line numbering Добавить нумерацию строк Add to Document Добавить в документ Address labels Адресные наклейки diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index bebe4d8..1bb5d28 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1650,6 +1650,42 @@ Rewind Na početak Forward Napred Stroke {n} of {total} Potez {n} od {total} + +# WordArt and connectors (#416). + WordArt Styles WordArt stilovi + Text Fill Popuna teksta + Text Outline Kontura teksta + Transform Transformacija + No Fill Bez popune + No Outline Bez konture + Reflection Odraz + No Reflection Bez odraza + No Transform Bez transformacije + Connect Shapes Poveži oblike + Straight Connector Pravi konektor + Straight Arrow Connector Pravi konektor sa strelicom + Elbow Connector Ugaoni konektor + Elbow Arrow Connector Ugaoni konektor sa strelicom + Curved Connector Zakrivljeni konektor + Curved Arrow Connector Zakrivljeni konektor sa strelicom + Arch Up Luk nagore + Arch Down Luk nadole + Slant Up Nagib nagore + Slant Down Nagib nadole + Inflate Naduvavanje + Deflate Izduvavanje + Chevron Up Ševron nagore + Chevron Down Ševron nadole + Triangle Up Trougao nagore + Triangle Down Trougao nadole + Solid Accent Puna boja isticanja + Light with Outline Svetlo sa konturom + Hollow Šuplje + Accent Gradient Gradijent boje isticanja + Dark with Shadow Tamno sa senkom + Soft Glow Meki sjaj + Accent with Reflection Isticanje sa odrazom + Warm Gradient with Outline Topli gradijent sa konturom Add line numbering Dodaj brojeve redova Add to Document Dodaj u dokument Address labels Adresne nalepnice diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c8641c6..ec3d1e1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1654,6 +1654,42 @@ Rewind На почетак Forward Напред Stroke {n} of {total} Потез {n} од {total} + +# WordArt and connectors (#416). + WordArt Styles WordArt стилови + Text Fill Попуна текста + Text Outline Контура текста + Transform Трансформација + No Fill Без попуне + No Outline Без контуре + Reflection Одраз + No Reflection Без одраза + No Transform Без трансформације + Connect Shapes Повежи облике + Straight Connector Прави конектор + Straight Arrow Connector Прави конектор са стрелицом + Elbow Connector Угаони конектор + Elbow Arrow Connector Угаони конектор са стрелицом + Curved Connector Закривљени конектор + Curved Arrow Connector Закривљени конектор са стрелицом + Arch Up Лук нагоре + Arch Down Лук надоле + Slant Up Нагиб нагоре + Slant Down Нагиб надоле + Inflate Надувавање + Deflate Издувавање + Chevron Up Шеврон нагоре + Chevron Down Шеврон надоле + Triangle Up Троугао нагоре + Triangle Down Троугао надоле + Solid Accent Пуна боја истицања + Light with Outline Светло са контуром + Hollow Шупље + Accent Gradient Градијент боје истицања + Dark with Shadow Тамно са сенком + Soft Glow Меки сјај + Accent with Reflection Истицање са одразом + Warm Gradient with Outline Топли градијент са контуром Add line numbering Додај бројеве редова Add to Document Додај у документ Address labels Адресне налепнице diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 161acae..3f7ba92 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1649,6 +1649,42 @@ Continuous numbering Sürekli numaralandırma Apply Uygula Convert Notes Notları Dönüştür + +# WordArt and connectors (#416). + WordArt Styles WordArt Stilleri + Text Fill Metin Dolgusu + Text Outline Metin Anahattı + Transform Dönüştür + No Fill Dolgu Yok + No Outline Anahat Yok + Reflection Yansıma + No Reflection Yansıma Yok + No Transform Dönüşüm Yok + Connect Shapes Şekilleri Bağla + Straight Connector Düz Bağlayıcı + Straight Arrow Connector Düz Oklu Bağlayıcı + Elbow Connector Dirsekli Bağlayıcı + Elbow Arrow Connector Dirsekli Oklu Bağlayıcı + Curved Connector Eğri Bağlayıcı + Curved Arrow Connector Eğri Oklu Bağlayıcı + Arch Up Yukarı Kemer + Arch Down Aşağı Kemer + Slant Up Yukarı Eğim + Slant Down Aşağı Eğim + Inflate Şişir + Deflate Söndür + Chevron Up Yukarı Şevron + Chevron Down Aşağı Şevron + Triangle Up Yukarı Üçgen + Triangle Down Aşağı Üçgen + Solid Accent Düz Vurgu + Light with Outline Anahatlı Açık + Hollow İçi Boş + Accent Gradient Vurgu Geçişi + Dark with Shadow Gölgeli Koyu + Soft Glow Yumuşak Parıltı + Accent with Reflection Yansımalı Vurgu + Warm Gradient with Outline Anahatlı Sıcak Geçiş Add line numbering Satır numaralandırma ekle Add to Document Belgeye Ekle Address labels Adres etiketleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f06f658..7249ca8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1652,6 +1652,42 @@ Rewind На початок Forward Вперед Stroke {n} of {total} Штрих {n} з {total} + +# WordArt and connectors (#416). + WordArt Styles Стилі WordArt + Text Fill Заливка тексту + Text Outline Контур тексту + Transform Перетворення + No Fill Без заливки + No Outline Без контуру + Reflection Відбиття + No Reflection Без відбиття + No Transform Без перетворення + Connect Shapes З'єднати фігури + Straight Connector Прямий з'єднувач + Straight Arrow Connector Прямий з'єднувач зі стрілкою + Elbow Connector Ламаний з'єднувач + Elbow Arrow Connector Ламаний з'єднувач зі стрілкою + Curved Connector Вигнутий з'єднувач + Curved Arrow Connector Вигнутий з'єднувач зі стрілкою + Arch Up Арка вгору + Arch Down Арка вниз + Slant Up Нахил угору + Slant Down Нахил униз + Inflate Роздуття + Deflate Стиснення + Chevron Up Шеврон угору + Chevron Down Шеврон униз + Triangle Up Трикутник угору + Triangle Down Трикутник униз + Solid Accent Суцільний акцент + Light with Outline Світлий з контуром + Hollow Порожнистий + Accent Gradient Акцентний градієнт + Dark with Shadow Темний із тінню + Soft Glow М'яке світіння + Accent with Reflection Акцент із відбиттям + Warm Gradient with Outline Теплий градієнт із контуром Add line numbering Додати нумерацію рядків Add to Document Додати до документа Address labels Адресні етикетки diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fa2d93f..59d855a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1652,6 +1652,42 @@ Rewind 倒回 Forward 前进 Stroke {n} of {total} 第 {n} 笔,共 {total} 笔 + +# WordArt and connectors (#416). + WordArt Styles 艺术字样式 + Text Fill 文本填充 + Text Outline 文本轮廓 + Transform 转换 + No Fill 无填充 + No Outline 无轮廓 + Reflection 映像 + No Reflection 无映像 + No Transform 无转换 + Connect Shapes 连接形状 + Straight Connector 直线连接符 + Straight Arrow Connector 直线箭头连接符 + Elbow Connector 肘形连接符 + Elbow Arrow Connector 肘形箭头连接符 + Curved Connector 曲线连接符 + Curved Arrow Connector 曲线箭头连接符 + Arch Up 上弯弧 + Arch Down 下弯弧 + Slant Up 上斜 + Slant Down 下斜 + Inflate 膨胀 + Deflate 收缩 + Chevron Up 上V形 + Chevron Down 下V形 + Triangle Up 正三角 + Triangle Down 倒三角 + Solid Accent 纯色强调 + Light with Outline 浅色带轮廓 + Hollow 空心 + Accent Gradient 强调渐变 + Dark with Shadow 深色带阴影 + Soft Glow 柔和发光 + Accent with Reflection 强调带映像 + Warm Gradient with Outline 暖色渐变带轮廓 Add line numbering 添加行号 Add to Document 添加到文档 Address labels 地址标签 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cf1aa24..eb3fdb1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1641,6 +1641,42 @@ Rewind 倒轉 Forward 快轉 Stroke {n} of {total} 第 {n} 筆,共 {total} 筆 + +# WordArt and connectors (#416). + WordArt Styles 文字藝術師樣式 + Text Fill 文字填滿 + Text Outline 文字外框 + Transform 轉換 + No Fill 無填滿 + No Outline 無外框 + Reflection 反射 + No Reflection 無反射 + No Transform 無轉換 + Connect Shapes 連接圖案 + Straight Connector 直線接點 + Straight Arrow Connector 直線箭頭接點 + Elbow Connector 肘形接點 + Elbow Arrow Connector 肘形箭頭接點 + Curved Connector 曲線接點 + Curved Arrow Connector 曲線箭頭接點 + Arch Up 上彎弧形 + Arch Down 下彎弧形 + Slant Up 向上傾斜 + Slant Down 向下傾斜 + Inflate 膨脹 + Deflate 收縮 + Chevron Up 向上V形 + Chevron Down 向下V形 + Triangle Up 正三角形 + Triangle Down 倒三角形 + Solid Accent 實心強調色 + Light with Outline 淺色加外框 + Hollow 空心 + Accent Gradient 強調色漸層 + Dark with Shadow 深色加陰影 + Soft Glow 柔和光暈 + Accent with Reflection 強調色加反射 + Warm Gradient with Outline 暖色漸層加外框 Add line numbering 新增行號 Add to Document 新增至文件 Address labels 地址標籤 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f710c01..2534957 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -476,6 +476,26 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.text(10.0, 10.5, 15.0, "A", a, true); pen.line_c(&[(3.0, 17.5), (17.0, 17.5)], orange); } + "textFill" => { + pen.text(10.0, 8.5, 13.0, "A", c, true); + pen.frect(3.0, 15.0, 17.0, 18.5, a); + } + "textOutline" => { + pen.text(10.0, 8.5, 13.0, "A", a, false); + pen.rect(3.0, 15.0, 17.0, 18.5, c); + } + "transform" => { + // Letters along an arch. + let arc: Vec<(f32, f32)> = (0..=12) + .map(|i| { + let t = std::f32::consts::PI * (1.0 - i as f32 / 12.0); + (10.0 + 7.5 * t.cos(), 15.0 - 9.0 * t.sin()) + }) + .collect(); + pen.line_c(&arc, a); + pen.text(10.0, 13.0, 8.0, "A", c, true); + pen.line(&[(2.5, 17.5), (17.5, 17.5)]); + } "dropCap" => { pen.text(6.0, 8.0, 12.0, "A", a, true); pen.lines(11.0, 17.0, &[4.0, 8.0, 12.0]); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 9a0f455..dad0b7e 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -192,7 +192,7 @@ fn controls(tab: &str) -> Vec { m("Page Number", "insert.pageNumber"), c("Text Box", "insert.textBox"), c("Quick Parts", "insert.quickParts"), - c("WordArt", "insert.wordArt"), + m("WordArt", "insert.wordArt"), c("Drop Cap", "insert.dropCap"), c("Signature Line", "insert.signatureLine"), c("Date & Time", "insert.dateTime"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4bc5076..25b4465 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,7 @@ pub mod table_pen; pub mod theme; pub mod widgets; pub mod window_geometry; +pub mod wordart_menus; pub mod zotero; use serde::{Deserialize, Serialize}; diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 54fa522..046b521 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -6,6 +6,9 @@ //! //! While dragging, nothing is laid out or rendered: the preview is the object's own pixels taken //! from its page's cached texture. The drop is one command, one relayout. +//! +//! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape +//! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free. use egui::{Color32, Painter, Pos2, Rect, Ui}; use serde_json::json; @@ -33,6 +36,40 @@ pub struct ObjectDrag { /// Smallest size it can be resized to, points. min: f32, picture: bool, + /// Dragging a connector's end (false: its start, true: its end) to this page point. + end: Option<(bool, (f32, f32))>, +} + +/// How near (screen points) a press must be to a connector's end to grab it. +const END_GRAB: f32 = 7.0; + +/// The selected connector's start and end on its page (points), and its layout. +fn connector_ends(app: &WordApp, layout: &DocLayout) -> Option<(ObjectHit, (f32, f32), (f32, f32))> { + let (pos, InlineObject::Shape { kind, float, .. }) = selected(app)? else { return None }; + if !kind.is_connector() { + return None; + } + let (flip_h, flip_v) = (float.flip_h, float.flip_v); + let o = layout.object(&pos, app.session.page_hint)?; + let (a, b) = wordcraft_doc::connector::ends(o.rect.x, o.rect.y, o.rect.w, o.rect.h, flip_h, flip_v); + Some((o, a, b)) +} + +fn point_on_screen(pages: &[Rect], scale: f32, page: usize, (x, y): (f32, f32)) -> Option { + pages.get(page).map(|p| egui::pos2(p.min.x + x * scale, p.min.y + y * scale)) +} + +/// The selected connector's end under `at` (screen): which (false: start), and the connector. +fn connector_end_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, bool)> { + let (o, a, b) = connector_ends(app, layout)?; + let near = |p: (f32, f32)| point_on_screen(pages, scale, o.page, p).is_some_and(|s| s.distance(at) <= END_GRAB); + if near(b) { + Some((o, true)) + } else if near(a) { + Some((o, false)) + } else { + None + } } /// The selected object: the selection is exactly one picture, shape or text box in the body. @@ -79,6 +116,21 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] if ui.input(|i| i.key_pressed(egui::Key::Escape)) { return true; // cancelled: dropped without a change } + if let Some((which, _)) = d.end { + if ui.input(|i| i.pointer.primary_down()) { + if let Some((_, x, y)) = crate::canvas::page_at(pages, layout, scale, at).filter(|(p, ..)| *p == d.object.page) { + d.end = Some((which, (x, y))); + d.drag.moved = true; + } + app.canvas.obj_drag = Some(d); + } else if d.drag.moved + && let Some((_, (x, y))) = d.end + { + let key = if which { "end" } else { "start" }; + let _ = app.run("shape.connect", json!({key: {"x": x, "y": y, "page": d.object.page}})); + } + return true; + } if ui.input(|i| i.pointer.primary_down()) { let shift = ui.input(|i| i.modifiers.shift); if d.drag.grab == Grab::Rotate { @@ -102,6 +154,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] if !(pressed || multi) || crate::canvas::editing_header_footer(app, layout) { return false; } + // A selected connector's end: drag it to glue it. + if pressed && let Some((object, which)) = connector_end_at(app, layout, pages, scale, at) { + let drag = Drag::new(Grab::Move, object.page, object.rect, 0.0, at); + app.canvas.obj_drag = Some(ObjectDrag { drag, object, min: 0.0, picture: false, end: Some((which, (0.0, 0.0))) }); + return true; + } let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false }; // Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group. let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command); @@ -119,7 +177,8 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] }; // Objects in table cells can be selected and resized, not moved; charts neither. if !fixed(app, &object) && (grab != Grab::Move || movable(&object)) { - app.canvas.obj_drag = Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), object, min, picture }); + app.canvas.obj_drag = + Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), object, min, picture, end: None }); } } true @@ -188,6 +247,23 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) { /// Draw the frame of the active object, or the drag preview. pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, pages: &[Rect], scale: f32) { + if let Some(d) = app.canvas.obj_drag.as_ref().filter(|d| d.end.is_some()) { + paint_end_drag(app, d, painter, t, layout, pages, scale); + return; + } + if let Some((o, a, b)) = connector_ends(app, layout) { + // A connector: a handle at each end (filled when glued). + let glued = match selected(app) { + Some((_, InlineObject::Shape { extra, .. })) => (extra.start.is_some(), extra.end.is_some()), + _ => (false, false), + }; + for (p, on) in [(a, glued.0), (b, glued.1)] { + if let Some(c) = point_on_screen(pages, scale, o.page, p) { + painter.circle(c, 4.5, if on { t.accent } else { Color32::WHITE }, egui::Stroke::new(1.2, t.accent)); + } + } + return; + } if let Some(d) = app.canvas.obj_drag.as_ref().filter(|d| d.drag.moved) { let Some(to) = screen(pages, scale, d.drag.to_page, d.drag.rect) else { return }; if d.drag.grab == Grab::Rotate { @@ -232,14 +308,57 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p } } +/// A connector's end being dragged: a line from its other end to the pointer, and the connection +/// sites of the shapes near the pointer (the one it would glue to, filled). +fn paint_end_drag(app: &WordApp, d: &ObjectDrag, painter: &Painter, t: &Tokens, layout: &DocLayout, pages: &[Rect], scale: f32) { + let Some((which, at)) = d.end.filter(|_| d.drag.moved) else { return }; + let Some((_, a, b)) = connector_ends(app, layout) else { return }; + let fixed = if which { a } else { b }; + let snap = wordcraft_engine::cmd::connectors::SNAP; + let mut best: Option<((f32, f32), f32)> = None; + if let Some(pg) = layout.pages.get(d.object.page) { + for it in &pg.items { + let wordcraft_layout::Placed::Object { rect, spin, story: StoryRef::Body, path, off, .. } = it else { continue }; + let pos = Pos { story: StoryRef::Body, path: path.clone(), off: *off }; + let Some(InlineObject::Shape { kind, .. }) = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue }; + if kind.is_connector() || pos == d.object.pos() { + continue; + } + let r = [rect.x, rect.y, rect.w, rect.h]; + // Sites of shapes the pointer is near. + if at.0 < rect.x - 4.0 * snap || at.0 > rect.right() + 4.0 * snap || at.1 < rect.y - 4.0 * snap || at.1 > rect.bottom() + 4.0 * snap { + continue; + } + for (i, _) in wordcraft_doc::connector::sites(*kind, rect.w, rect.h).iter().enumerate() { + let Some(p) = wordcraft_doc::connector::site_point(*kind, r, *spin, i as u32) else { continue }; + let dist = ((p.0 - at.0).powi(2) + (p.1 - at.1).powi(2)).sqrt(); + if let Some(c) = point_on_screen(pages, scale, d.object.page, p) { + painter.circle_stroke(c, 3.5, egui::Stroke::new(1.0, t.accent)); + } + if dist <= snap && best.is_none_or(|b| dist < b.1) { + best = Some((p, dist)); + } + } + } + } + let to = best.map_or(at, |b| b.0); + if let (Some(f), Some(e)) = (point_on_screen(pages, scale, d.object.page, fixed), point_on_screen(pages, scale, d.object.page, to)) { + painter.line_segment([f, e], egui::Stroke::new(1.5, t.accent)); + painter.circle(e, 4.5, if best.is_some() { t.accent } else { Color32::WHITE }, egui::Stroke::new(1.2, t.accent)); + } +} + /// The pointer over an object or a handle (or dragging): its cursor. pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option { if let Some(d) = &app.canvas.obj_drag { - return Some(d.drag.grab.cursor()); + return Some(if d.end.is_some() { egui::CursorIcon::Crosshair } else { d.drag.grab.cursor() }); } if crate::canvas::editing_header_footer(app, layout) { return None; } + if connector_end_at(app, layout, pages, scale, at).is_some() { + return Some(egui::CursorIcon::Crosshair); + } grab_at(app, layout, pages, scale, at).map(|(o, g)| if fixed(app, &o) { egui::CursorIcon::Default } else { g.cursor() }) } @@ -305,6 +424,7 @@ mod tests { story: None, freeform: None, effects: Default::default(), + extra: Default::default(), }; let (app, hit) = app_with(shape); assert!(!fixed(&app, &hit)); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5638975..2d08502 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -299,7 +299,7 @@ fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { }); } -fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { +pub(crate) fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { mi_text(ui, app, tl!(label), id, params); } @@ -406,6 +406,14 @@ fn home(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "enclose", None, "Enclose Characters", "format.enclose", json!({}), false); ui.add_space(4.0); menu_button(ui, app, "effects", None, "Text Effects and Typography", false, |ui, app| { + let theme = app.session.doc.settings.theme_colors.clone(); + crate::wordart_menus::style_gallery(ui, app, "format.textEffects"); + ui.separator(); + ui.menu_button(tl!("Text Fill"), |ui| crate::wordart_menus::fill_menu(ui, app, &theme)); + let outline = crate::wordart_menus::current_outline(app); + ui.menu_button(tl!("Text Outline"), |ui| crate::wordart_menus::outline_menu(ui, app, &theme, outline)); + crate::wordart_menus::effects_menu(ui, app, &theme, false); + ui.separator(); mi(ui, app, "Outline", "format.outline", json!({})); mi(ui, app, "Shadow", "format.shadow", json!({})); mi(ui, app, "Small Caps", "format.smallCaps", json!({})); @@ -625,6 +633,17 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { } ui.separator(); mi(ui, app, "New Drawing Canvas", "insert.canvas", json!({})); + // Connectors: glue their ends to shapes by dragging them (shape.connect). + for (l, k, arrow) in [ + ("Straight Connector", "straightConnector", "none"), + ("Straight Arrow Connector", "straightConnector", "end"), + ("Elbow Connector", "elbowConnector", "none"), + ("Elbow Arrow Connector", "elbowConnector", "end"), + ("Curved Connector", "curvedConnector", "none"), + ("Curved Arrow Connector", "curvedConnector", "end"), + ] { + mi(ui, app, l, "insert.shape", json!({"kind": k, "arrow": arrow})); + } }); stack(ui, |ui| { small(ui, app, "icons", Some("Icons"), "Icons", "insert.icon", json!({}), false); @@ -680,7 +699,9 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { } mi(ui, app, "Field…", "ui.dialog", json!({"name": "field"})); }); - small(ui, app, "wordArt", Some("WordArt"), "WordArt", "insert.wordArt", json!({}), false); + menu_button(ui, app, "wordArt", Some("WordArt"), "WordArt", false, |ui, app| { + crate::wordart_menus::style_gallery(ui, app, "insert.wordArt") + }); small(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", "insert.dropCap", json!({}), false); }); stack(ui, |ui| { @@ -1556,6 +1577,21 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) { }); }); } + group(ui, "WordArt Styles", None, app, |ui, app| { + menu_button(ui, app, "wordArt", Some("Styles"), "WordArt Styles", true, |ui, app| { + crate::wordart_menus::style_gallery(ui, app, "wordArt.style") + }); + stack(ui, |ui| { + menu_button(ui, app, "textFill", Some("Text Fill"), "Text Fill", false, |ui, app| crate::wordart_menus::fill_menu(ui, app, &theme)); + let outline = crate::wordart_menus::current_outline(app); + menu_button(ui, app, "textOutline", Some("Text Outline"), "Text Outline", false, |ui, app| { + crate::wordart_menus::outline_menu(ui, app, &theme, outline) + }); + menu_button(ui, app, "transform", Some("Text Effects"), "Text Effects", false, |ui, app| { + crate::wordart_menus::effects_menu(ui, app, &theme, true) + }); + }); + }); shape_text_group(app, ui); group(ui, "Arrange", None, app, |ui, app| { big(ui, app, "position", "Position", "arrange.position", json!({}), false); diff --git a/crates/ui-egui/src/wordart_menus.rs b/crates/ui-egui/src/wordart_menus.rs new file mode 100644 index 0000000..a9f7fac --- /dev/null +++ b/crates/ui-egui/src/wordart_menus.rs @@ -0,0 +1,222 @@ +//! WordArt menus: the style gallery (Insert › WordArt, Home › Text Effects, Shape Format › +//! WordArt Styles), Text Fill, Text Outline and Text Effects (Shadow, Glow, Reflection, +//! Transform). Previews are drawn in code from our own styles and the warp geometry itself. + +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; +use wordcraft_doc::Rgb; +use wordcraft_doc::wordart::{ART_STYLES, DRAWN_WARPS, TextFill, TextWarp, art_style}; + +use crate::WordApp; +use crate::ribbon::mi; +use crate::theme::{Tokens, semibold}; +use crate::widgets::color_grid; + +fn c32(c: Rgb) -> Color32 { + Color32::from_rgb(c.0, c.1, c.2) +} + +/// A clickable preview cell `w` × `h`; true when clicked. +fn cell(ui: &mut Ui, w: f32, h: f32, tip: &str, paint: impl FnOnce(&egui::Painter, Rect)) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click()); + let bg = if resp.hovered() { t.hover } else { t.input }; + ui.painter().rect_filled(r, 3.0, bg); + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + paint(ui.painter(), r.shrink(3.0)); + resp.on_hover_text(crate::tl!(tip)).clicked() +} + +/// A letter "A" drawn in WordArt style `id` (its fill, outline, shadow and glow, roughly). +fn paint_style(p: &egui::Painter, r: Rect, id: &str, theme: &[Rgb]) { + let Some(fx) = art_style(id, theme) else { return }; + let font = semibold(r.height() * 0.8); + let at = r.center(); + let text = |dx: f32, dy: f32, c: Color32| { + p.text(at + vec2(dx, dy), Align2::CENTER_CENTER, "A", font.clone(), c); + }; + if let Some(g) = fx.glow { + let c = c32(g.color).gamma_multiply(0.35); + for (dx, dy) in [(-2.0, 0.0), (2.0, 0.0), (0.0, -2.0), (0.0, 2.0), (-1.5, -1.5), (1.5, 1.5), (-1.5, 1.5), (1.5, -1.5)] { + text(dx, dy, c); + } + } + if fx.shadow.is_some() { + text(1.5, 1.5, Color32::from_black_alpha(110)); + } + if fx.reflection.is_some() { + p.rect_filled( + Rect::from_min_max(pos2(r.center().x - r.width() * 0.25, r.max.y - 3.0), pos2(r.center().x + r.width() * 0.25, r.max.y)), + 1.0, + Color32::from_gray(190), + ); + } + if let Some(o) = fx.outline.and_then(|o| o.color) { + for (dx, dy) in [(-0.8, 0.0), (0.8, 0.0), (0.0, -0.8), (0.0, 0.8)] { + text(dx, dy, c32(o)); + } + } + match &fx.fill { + Some(TextFill::None) => text(0.0, 0.0, Tokens::get(p.ctx()).input), + Some(f) => { + let c = f.main_color().map_or(Color32::BLACK, c32); + text(0.0, 0.0, c); + if let TextFill::Gradient { stops, .. } = f + && let Some(first) = stops.first() + { + // The gradient's first colour over the top half. + let top = Rect::from_min_max(r.min, pos2(r.max.x, r.center().y)); + p.with_clip_rect(top).text(at, Align2::CENTER_CENTER, "A", font.clone(), c32(first.color)); + } + } + None => text(0.0, 0.0, Color32::BLACK), + } +} + +/// Our WordArt styles as a grid; a click runs `cmd` with `{"style": id}`. +pub fn style_gallery(ui: &mut Ui, app: &mut WordApp, cmd: &str) { + let theme = app.session.doc.settings.theme_colors.clone(); + egui::Grid::new(("wordart-styles", cmd)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, (id, label)) in ART_STYLES.iter().enumerate() { + if cell(ui, 40.0, 36.0, label, |p, r| paint_style(p, r, id, &theme)) { + let _ = app.run(cmd, json!({"style": id})); + ui.close(); + } + if i % 4 == 3 { + ui.end_row(); + } + } + }); +} + +/// Text Fill: automatic (the text's colour), a colour, no fill (hollow), or a gradient of +/// theme colours. +pub fn fill_menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb]) { + mi(ui, app, "Automatic", "format.textEffects", json!({"fill": null})); + mi(ui, app, "No Fill", "format.textEffects", json!({"fill": "none"})); + if let Some(hex) = color_grid(ui, theme) { + let _ = app.run("format.textEffects", json!({"fill": hex})); + ui.close(); + } + ui.add_space(4.0); + let slot = |i: usize| theme.get(i).copied().unwrap_or(Rgb::BLACK); + ui.horizontal(|ui| { + for (a, b) in [(slot(4), slot(7)), (slot(5), slot(8)), (slot(1), slot(4)), (slot(2), slot(9))] { + let clicked = cell(ui, 40.0, 18.0, "Text Fill", |p, r| { + let n = 12; + for k in 0..n { + let f = k as f32 / (n - 1) as f32; + let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * f) as u8; + let c = Color32::from_rgb(mix(a.0, b.0), mix(a.1, b.1), mix(a.2, b.2)); + let y0 = r.min.y + r.height() * k as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(r.min.x, y0), pos2(r.max.x, y0 + r.height() / n as f32 + 0.5)), 0.0, c); + } + }); + if clicked { + let _ = app.run("format.textEffects", json!({"fill": {"gradient": [a.hex(), b.hex()], "angle": 90}})); + ui.close(); + } + } + }); +} + +/// Text Outline: automatic (none set), no outline, a colour, a weight. +pub fn outline_menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb], current: Option<(Rgb, f32)>) { + mi(ui, app, "Automatic", "format.textEffects", json!({"outline": null})); + mi(ui, app, "No Outline", "format.textEffects", json!({"outline": "none"})); + let width = current.map_or(0.75, |c| c.1); + if let Some(hex) = color_grid(ui, theme) { + let _ = app.run("format.textEffects", json!({"outline": {"color": hex, "width": width}})); + ui.close(); + } + ui.separator(); + ui.menu_button(crate::tl!("Weight"), |ui| { + let color = current.map_or(Rgb::BLACK, |c| c.0).hex(); + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0] { + mi(ui, app, &format!("{w} pt"), "format.textEffects", json!({"outline": {"color": color, "width": w}})); + } + }); +} + +/// A warp's preview: its top, middle and bottom lines and a few verticals, bent as the text is. +fn paint_warp(p: &egui::Painter, r: Rect, preset: &str, c: Color32) { + let Some(tw) = TextWarp::new(preset) else { return }; + let frame = wordcraft_geom::Rect::new(r.min.x, r.min.y, r.width(), r.height()); + let Some(w) = wordcraft_layout::wordart::Warp::new(&tw, frame, 0.3) else { return }; + let pt = |u: f32, v: f32| { + let (x, y) = w.map(u, v); + Pos2::new(x, y) + }; + for v in [0.0, 1.0] { + p.add(egui::Shape::line((0..=24).map(|i| pt(i as f32 / 24.0, v)).collect(), Stroke::new(1.0, c))); + } + p.add(egui::Shape::line((0..=24).map(|i| pt(i as f32 / 24.0, 0.5)).collect(), Stroke::new(1.0, c.gamma_multiply(0.5)))); + for u in [0.0, 0.25, 0.5, 0.75, 1.0] { + p.line_segment([pt(u, 0.0), pt(u, 1.0)], Stroke::new(1.0, c.gamma_multiply(0.6))); + } +} + +/// Text Effects: Shadow, Glow and Reflection (and Transform, for a shape's text). +pub fn effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb], transform: bool) { + ui.menu_button(crate::tl!("Shadow"), |ui| { + mi(ui, app, "No Shadow", "format.textEffects", json!({"shadow": null})); + ui.separator(); + for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS { + mi(ui, app, label, "format.textEffects", json!({"shadow": id})); + } + }); + ui.menu_button(crate::tl!("Glow"), |ui| { + mi(ui, app, "No Glow", "format.textEffects", json!({"glow": null})); + ui.separator(); + for size in [3.0, 5.0, 8.0, 11.0] { + mi(ui, app, &format!("{size} pt"), "format.textEffects", json!({"glow": size})); + } + ui.separator(); + ui.menu_button(crate::tl!("Glow Colors"), |ui| { + if let Some(hex) = color_grid(ui, theme) { + let _ = app.run("format.textEffects", json!({"glow": {"color": hex, "size": 5.0}})); + ui.close(); + } + }); + }); + ui.menu_button(crate::tl!("Reflection"), |ui| { + mi(ui, app, "No Reflection", "format.textEffects", json!({"reflection": null})); + ui.separator(); + for size in [25.0, 50.0, 90.0] { + mi(ui, app, &format!("{size:.0}%"), "format.textEffects", json!({"reflection": {"size": size}})); + } + }); + if transform { + ui.menu_button(crate::tl!("Transform"), |ui| transform_menu(ui, app)); + } +} + +/// Text Effects › Transform: the warps WordCraft bends text along, as previews. +pub fn transform_menu(ui: &mut Ui, app: &mut WordApp) { + let t = Tokens::get(ui.ctx()); + mi(ui, app, "No Transform", "wordArt.transform", json!({"preset": "none"})); + ui.add_space(4.0); + egui::Grid::new("wordart-warps").spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, (id, label)) in DRAWN_WARPS.iter().enumerate() { + if cell(ui, 46.0, 32.0, label, |p, r| paint_warp(p, r.shrink(2.0), id, t.accent)) { + let _ = app.run("wordArt.transform", json!({"preset": id})); + ui.close(); + } + if i % 4 == 3 { + ui.end_row(); + } + } + }); +} + +/// The selected shape's text outline (colour, width), for the Weight menu. +pub fn current_outline(app: &WordApp) -> Option<(Rgb, f32)> { + let story = match wordcraft_engine::cmd::objects::object_selection(&app.session) { + Some((_, wordcraft_doc::InlineObject::Shape { story: Some(id), .. })) => wordcraft_doc::StoryRef::Part(*id), + _ => app.session.sel.focus.story, + }; + let start = app.session.doc.start_of(story); + let para = app.session.doc.para_at(&start)?; + let fx = para.props_at(0).text_effects.as_deref()?; + fx.outline.and_then(|o| o.color.map(|c| (c, o.width))) +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5bebd1b..a265126 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (WordArt effects, text warps and connectors, #416; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -58,7 +58,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | -| 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; `wps:bodyPr` `vert` and `anchor`, linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369) | +| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` 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; `wps:bodyPr` `vert` and `anchor`, linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) | @@ -88,6 +89,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) | | 2026-10-11 | trivial | Content controls read and written with their properties and nesting (#381) | | 2026-10-11 | trivial | Charts made in WordCraft written as chart parts and read back as editable (#409) | | 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit read and written (#407) | diff --git a/docs/gaps.md b/docs/gaps.md index 5ef45e3..67acac7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (#24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (#10: WordArt and connectors landed, #416; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -20,7 +20,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 7 | **Dialog depth**: 46 modal dialogs vs Word's ~100 (Compare, Protect, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | -| 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | +| 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (editing charts from Word, more chart types and formatting, SmartArt layouts) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; `insert.smartArt` missing | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #10: WordArt text effects and Transform warps, connectors glued to shapes (#416) | | 2026-10-11 | trivial | #5 narrowed: content controls kept, shown and edited (#381); move tracking remains | | 2026-10-11 | trivial | Insert › Chart landed (#409); gap #13 narrowed to editing Word's charts, more types and SmartArt, 35–55 h | | 2026-10-11 | trivial | Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs landed (#407); gap #7 at 30 dialogs, 16–24 h; duplicated #7 rows merged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index aad15b0..ceb446b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,13 +2,13 @@ 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. -**453 of 470 catalog features have commands (96.4%).** +**458 of 475 catalog features have commands (96.4%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | -| Home | 101 | 102 | +| Home | 102 | 103 | | Insert | 46 | 53 | | Draw | 10 | 11 | | Design | 10 | 10 | @@ -22,7 +22,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Table Layout | 32 | 32 | | Equation | 21 | 22 | | Picture Format | 14 | 14 | -| Shape Format | 7 | 7 | +| Shape Format | 11 | 11 | | Chart Design | 5 | 5 | | Header & Footer | 5 | 5 | | Editing | 38 | 38 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 04a60e5..3b54ae3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (WordArt and connectors landed, #416; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **453 / 470 (96.4%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **458 / 475 (96.4%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -67,6 +67,8 @@ full parity for the area. | References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | +| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list 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), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | @@ -249,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Pictures, shapes, text boxes, WordArt: WordArt text effects and Transform warps, connectors glued to shapes (#416); catalog 423/448 → 428/453 (94.5%); no percentage change | | 2026-10-11 | trivial | Content controls kept, shown and edited, Developer › Controls (#381); catalog +11 rows, all live (402/434 → 413/445, 92.8%); no percentage change | | 2026-10-11 | trivial | Insert › Chart (#409): charts row updated, catalog 408/439; no percentage change | | 2026-10-11 | trivial | Accessibility 35% → 50%: Alt Text pane for every object and tables, decorative flag, deeper checker with an Accessibility pane (#386); weight 1, headline unchanged |