WordArt text effects and transforms, and shape connectors (#416) (#481)

* WordArt text effects and transforms, and shape connectors (#416)

- Model: WordArt text effects on runs (fill solid/gradient/none, outline,
  shadow, glow, reflection), text warps on shapes (all DrawingML presets
  kept, adjust values capped), connector kinds (straight, elbow, curved)
  with glued ends, arrowheads and drawing ids (ShapeExtra).
- DOCX: w14:textFill/textOutline/shadow/glow/reflection in w:rPr,
  a:prstTxWarp in wps:bodyPr, wps:cNvCnPr a:stCxn/a:endCxn,
  a:headEnd/a:tailEnd; glued-to shapes keep their docPr ids.
- Layout/render/PDF: text with effects drawn from glyph outlines as paint
  layers (gradients, shadow/glow bands, faded reflection); warped text
  boxes bent along arch up/down, circle, wave, slant, inflate/deflate,
  chevron and triangle; connectors and arrowheads drawn as paths.
- Engine: insert.wordArt makes a floating WordArt text box with our own
  styles; format.textEffects, wordArt.style/textFill/textOutline/transform,
  shape.connect; glued connectors re-route after object edits.
- UI: WordArt style gallery (Insert, Home › Text Effects, Shape Format ›
  WordArt Styles), Text Fill/Outline/Effects menus, Transform previews
  drawn from the warp geometry, connectors in Insert › Shapes, drag a
  connector end onto a shape to glue it.

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

* Fix tests for ShapeExtra, regenerate parity checklist, fmt (#416)

* Regenerate parity checklist after rebase (#416)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 22:51:01 +02:00 committed by GitHub
parent 053bb0fd85
commit 2ffba93b51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 3470 additions and 71 deletions

1
Cargo.lock generated
View File

@ -5143,6 +5143,7 @@ version = "0.5.0"
dependencies = [
"image",
"js-sys",
"kurbo",
"log",
"serde",
"serde_json",

View File

@ -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 |

263
crates/doc/src/connector.rs Normal file
View File

@ -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<TextWarp>,
/// What a connector's start and end are glued to.
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<ConnEnd>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<ConnEnd>,
/// 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<Vec<PathSeg>> {
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<ConnEnd>| {
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));
}
}

View File

@ -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(),
}
}

View File

@ -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()

View File

@ -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

View File

@ -281,6 +281,10 @@ pub struct CharProps {
pub emboss: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub engrave: Option<bool>,
/// 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<Box<crate::wordart::TextEffects>>,
/// BCP 47 language tag (proofing).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
@ -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;
}

View File

@ -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<Box<crate::wordart::TextEffects>>,
pub link: Option<String>,
pub ins: Option<u32>,
pub del: Option<u32>,
@ -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,

View File

@ -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();

372
crates/doc/src/wordart.rs Normal file
View File

@ -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<TextFill>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outline: Option<TextOutline>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shadow: Option<Shadow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub glow: Option<Glow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reflection: Option<Reflection>,
}
/// 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<GradStop>,
#[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<Rgb>,
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<TextFill> {
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<GradStop> = 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<Rgb> {
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<TextEffects> {
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<TextWarp> {
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<i64> {
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<TextWarp> {
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}");
}
}
}

View File

@ -66,7 +66,13 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>>
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.

View File

@ -77,6 +77,16 @@ fn scheme_slot(name: &str) -> Option<usize> {
})
}
/// Theme colour `name` (`accent1`, `tx1`…) of `theme`.
pub(crate) fn scheme(name: &str, theme: &[Rgb]) -> Option<Rgb> {
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;

View File

@ -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>>,
/// 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<u32>,
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<Document, DocxError> {
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<Document, DocxError> {
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<u32>) {
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> {

View File

@ -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<String, String>,
/// The theme's colours (for WordArt effects in theme colours).
pub theme: Vec<Rgb>,
}
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;
}
}
_ => {}
}
}

View File

@ -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<TextWarp> {
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::<i64>().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| {

View File

@ -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<TextFill> {
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<GradStop> = 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))
}

View File

@ -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<u32>,
z: u32,
/// Note part id → file note id, in first-reference order.
footnotes: Vec<u32>,
@ -131,7 +134,9 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, 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()

View File

@ -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)?

View File

@ -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() {

View File

@ -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");
}
}

View File

@ -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 = &paras(&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 = &paras(&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<u8> {
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()
}

View File

@ -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(),
}
}

View File

@ -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

View File

@ -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<CommandSpec> {
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<u32, Pos>, Vec<Pos>, Vec<Pos>) {
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<ConnEnd>, 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<Pos> {
let path: Option<Vec<u32>> =
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<u32, CmdError> {
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<Option<ConnEnd>>; 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)}))
}

View File

@ -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).

View File

@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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() {

View File

@ -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());

View File

@ -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<CommandSpec> {
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<Rgb> {
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<u32> {
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<Rgb> {
p::str(v, k).and_then(Rgb::parse)
}
/// A change to one effect: `None` leaves it, `Some(None)` removes it.
type Change<T> = Option<Option<T>>;
fn parse_fill(v: Option<&Value>) -> Result<Change<TextFill>, 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<Rgb> = 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<Change<TextOutline>, 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<Change<Shadow>, 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<Change<Glow>, 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<Change<Reflection>, 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<Pos> {
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::<f32>()
}
/// `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}))
}

View File

@ -164,3 +164,5 @@ mod tests;
mod tests_bidi;
#[cfg(test)]
mod tests_typing;
#[cfg(test)]
mod tests_wordart;

View File

@ -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") {

View File

@ -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"}));
}

View File

@ -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 }

View File

@ -121,6 +121,15 @@ pub enum Draw {
rect: Rect,
items: Vec<Draw>,
},
/// 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<wordcraft_doc::wordart::TextEffects>,
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<D
let mut out = Vec::new();
let ha = if opts.dim_header { 0.45 } else { 1.0 };
let ba = if opts.dim_body { 0.45 } else { 1.0 };
for it in &page.header {
item(doc, it, opts, ha, &mut out);
}
for it in &page.footer {
item(doc, it, opts, ha, &mut out);
}
for it in &page.items {
item(doc, it, opts, ba, &mut out);
for (items, a) in [(&page.header, ha), (&page.footer, ha), (&page.items, ba)] {
let warps = warped_boxes(doc, items);
let mut done: Vec<u32> = 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<D
out
}
/// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their
/// story, frame and warp. Empty, without a walk of the document, when none is.
fn warped_boxes(doc: &Document, items: &[Placed]) -> 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<Draw>,
) {
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<wordcraft_doc::wordart::TextEffects>, 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<Draw>,
) {
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<Rgb>,
stroke_width: f32,
effects: wordcraft_doc::effects::ShapeEffects,
extra: &wordcraft_doc::connector::ShapeExtra,
out: &mut Vec<Draw>,
) {
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 { .. } => {

View File

@ -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<Arc<wordcraft_doc::freeform::Freeform>>,
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 },
]

View File

@ -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,

View File

@ -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();

View File

@ -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<f32>,
}
/// 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<kurbo::Rect> {
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<ArtLayer> {
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<ArtPaint> {
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<Warp> {
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<Point> = 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<kurbo::Rect> {
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<f32> = (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}");
}
}
}

View File

@ -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<Path> {
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 };

View File

@ -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<peniko::ColorStop> =
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();

View File

@ -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, .. } => {

View File

@ -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

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

View File

@ -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

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

View File

@ -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 宛名ラベル

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

View File

@ -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

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

View File

@ -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 Адресные наклейки

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

View File

@ -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

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

View File

@ -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 Адресне налепнице

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

View File

@ -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

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

View File

@ -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 Адресні етикетки

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

View File

@ -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 地址标签

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

View File

@ -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 地址標籤

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

View File

@ -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]);

View File

@ -192,7 +192,7 @@ fn controls(tab: &str) -> Vec<Control> {
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"),

View File

@ -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};

View File

@ -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<Pos2> {
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<egui::CursorIcon> {
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));

View File

@ -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);

View File

@ -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)))
}

View File

@ -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) |

View File

@ -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 |

View File

@ -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 |

View File

@ -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 |