* 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:
parent
053bb0fd85
commit
2ffba93b51
|
|
@ -5143,6 +5143,7 @@ version = "0.5.0"
|
|||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
"kurbo",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,27 @@ fn every_char_prop_round_trips() {
|
|||
shadow: Some(true),
|
||||
emboss: Some(false),
|
||||
engrave: Some(true),
|
||||
// WordArt effects ([MS-DOCX] w14 run properties).
|
||||
text_effects: Some(Box::new(wordcraft_doc::wordart::TextEffects {
|
||||
fill: Some(wordcraft_doc::wordart::TextFill::Gradient {
|
||||
stops: vec![
|
||||
wordcraft_doc::wordart::GradStop { pos: 0.0, color: Rgb(0x15, 0x60, 0x82), transparency: 0.0 },
|
||||
wordcraft_doc::wordart::GradStop { pos: 100.0, color: Rgb(0xE9, 0x71, 0x32), transparency: 25.0 },
|
||||
],
|
||||
angle: 90.0,
|
||||
}),
|
||||
outline: Some(wordcraft_doc::wordart::TextOutline { color: Some(Rgb(0x0E, 0x28, 0x41)), width: 1.5, transparency: 0.0 }),
|
||||
shadow: Some(wordcraft_doc::effects::Shadow {
|
||||
color: Rgb::BLACK,
|
||||
transparency: 60.0,
|
||||
blur: 4.0,
|
||||
distance: 3.0,
|
||||
angle: 45.0,
|
||||
rot_with_shape: false,
|
||||
}),
|
||||
glow: Some(wordcraft_doc::effects::Glow { color: Rgb(0x0F, 0x9E, 0xD5), size: 5.0, transparency: 40.0 }),
|
||||
reflection: Some(wordcraft_doc::wordart::Reflection::default()),
|
||||
})),
|
||||
lang: Some("fr-FR".into()),
|
||||
no_proof: Some(true),
|
||||
rtl: Some(false),
|
||||
|
|
@ -736,6 +757,7 @@ fn toc_field_skips_nested_stories() {
|
|||
story: Some(story),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
};
|
||||
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
|
||||
|
|
@ -859,6 +881,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
|||
story: Some(story),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let star = InlineObject::Shape {
|
||||
kind: ShapeKind::Star,
|
||||
|
|
@ -871,6 +894,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
|||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
|
||||
let mut p = Paragraph::with_text("shapes ", CharProps::default());
|
||||
|
|
@ -942,6 +966,7 @@ fn groups_round_trip() {
|
|||
glow: None,
|
||||
soft_edge: Some(2.5),
|
||||
},
|
||||
extra: Default::default(),
|
||||
};
|
||||
let tb = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
|
|
@ -954,6 +979,7 @@ fn groups_round_trip() {
|
|||
story: Some(story),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() };
|
||||
let group = InlineObject::Group {
|
||||
|
|
@ -1015,6 +1041,7 @@ fn drawing_canvas_round_trips() {
|
|||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let style = CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: Some(Rgb(0x20, 0x40, 0x80)), stroke_width: 1.5 };
|
||||
let canvas = InlineObject::Group {
|
||||
|
|
@ -1062,6 +1089,7 @@ fn rotated_drawing_canvas_round_trips() {
|
|||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let canvas = |float: Float| InlineObject::Group {
|
||||
w: 200.0,
|
||||
|
|
@ -1295,6 +1323,7 @@ fn self_showing_text_box_saves_bounded() {
|
|||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
for _ in 0..30 {
|
||||
d.insert_object(
|
||||
|
|
@ -1393,6 +1422,7 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() {
|
|||
story: None,
|
||||
freeform: Some(Arc::new(f)),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
// The pen stroke was the seventh drawn: Ink Replay's order comes back too.
|
||||
let pen = shape(
|
||||
|
|
@ -1452,6 +1482,7 @@ fn shape_effects_round_trip() {
|
|||
story: None,
|
||||
freeform: None,
|
||||
effects,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(1, shape, &CharProps::default()).unwrap();
|
||||
|
|
@ -1527,6 +1558,7 @@ fn rotation_and_flips_round_trip() {
|
|||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let square = Float { wrap: Wrap::Square, ..Default::default() };
|
||||
let tri = shape(ShapeKind::Triangle, Float { rot: 90.0, flip_h: true, ..square.clone() });
|
||||
|
|
@ -1583,6 +1615,7 @@ fn rotated_group_and_shadow_rotation_round_trip() {
|
|||
story: None,
|
||||
freeform: None,
|
||||
effects: ShapeEffects { shadow: Some(Shadow { rot_with_shape, ..Default::default() }), ..Default::default() },
|
||||
extra: Default::default(),
|
||||
};
|
||||
let group = InlineObject::Group {
|
||||
w: 120.0,
|
||||
|
|
@ -1632,6 +1665,7 @@ fn wrap_polygons_round_trip() {
|
|||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
// A hand-edited polygon, including points outside the frame.
|
||||
let poly = WrapPolygon::new(true, [(-300, 0), (21_600, 1200), (18_000, 21_900), (0, 21_600)]).map(Arc::new);
|
||||
|
|
@ -1755,6 +1789,7 @@ fn wrap_text_sides_round_trip() {
|
|||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let sides =
|
||||
[(Wrap::Square, WrapText::Left), (Wrap::Tight, WrapText::Right), (Wrap::Through, WrapText::Largest), (Wrap::Square, WrapText::BothSides)];
|
||||
|
|
@ -1808,6 +1843,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
|
|||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let described = shape(AltText { text: "Process flow & \"steps\"".into(), ..Default::default() });
|
||||
let decorative = shape(AltText { decorative: true, ..Default::default() });
|
||||
|
|
@ -1856,3 +1892,87 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
|
|||
let Some(Block::Table(t)) = r.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!() };
|
||||
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year")));
|
||||
}
|
||||
|
||||
/// WordArt's warp (`a:prstTxWarp` with its adjust value) and connectors glued to shapes
|
||||
/// (`a:stCxn`/`a:endCxn`, with arrowheads) come back as saved; a shape no connector is glued to
|
||||
/// loses its drawing id, and hostile values are reduced.
|
||||
#[test]
|
||||
fn warps_and_glued_connectors_round_trip() {
|
||||
use wordcraft_doc::connector::{ConnEnd, ShapeExtra};
|
||||
use wordcraft_doc::wordart::TextWarp;
|
||||
let mut d = Document::new();
|
||||
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Bent", CharProps::default()))]);
|
||||
let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y: 10.0, ..Default::default() };
|
||||
let shape = |kind, story, extra| InlineObject::Shape {
|
||||
kind,
|
||||
w: 72.0,
|
||||
h: 36.0,
|
||||
fill: None,
|
||||
stroke: Some(Rgb(0, 0, 0)),
|
||||
stroke_width: 1.0,
|
||||
float: float(0.0),
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra,
|
||||
};
|
||||
let mut warp = TextWarp::new("textArchUp").unwrap();
|
||||
warp.set_adj("adj", 9_000_000);
|
||||
let art = shape(ShapeKind::TextBox, Some(story), ShapeExtra { id: 5, warp: Some(warp), ..Default::default() });
|
||||
let target = shape(ShapeKind::Ellipse, None, ShapeExtra { id: 7, ..Default::default() });
|
||||
let lonely = shape(ShapeKind::Rectangle, None, ShapeExtra { id: 9, ..Default::default() });
|
||||
let link = ShapeExtra { start: Some(ConnEnd { id: 5, site: 3 }), end: Some(ConnEnd { id: 7, site: 2 }), arrow_end: true, ..Default::default() };
|
||||
let mut conn = shape(ShapeKind::ElbowConnector, None, link);
|
||||
if let InlineObject::Shape { float: f, .. } = &mut conn {
|
||||
*f = Float { flip_v: true, ..float(80.0) };
|
||||
}
|
||||
let mut p = Paragraph::with_text("", CharProps::default());
|
||||
for o in [art.clone(), conn.clone(), target.clone(), lonely] {
|
||||
let end = p.len();
|
||||
p.insert_object(end, o, &CharProps::default()).unwrap();
|
||||
}
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let got = ¶s(&r)[0].objects;
|
||||
// The glued-to shapes keep their ids, the connector its ends, arrowhead and flip.
|
||||
let extra = |o: &InlineObject| match o {
|
||||
InlineObject::Shape { extra, .. } => extra.clone(),
|
||||
_ => panic!("{o:?}"),
|
||||
};
|
||||
assert_eq!(extra(&got[0]), extra(&art));
|
||||
assert_eq!(extra(&got[1]), extra(&conn));
|
||||
assert!(matches!(&got[1], InlineObject::Shape { kind: ShapeKind::ElbowConnector, float, .. } if float.flip_v));
|
||||
assert_eq!(extra(&got[2]), extra(&target));
|
||||
assert_eq!(extra(&got[3]).id, 0, "nothing is glued to it");
|
||||
|
||||
// Hostile: an unknown warp, a huge adjust value, a glue to a missing shape.
|
||||
let xml = wordcraft_docx::write(&r).unwrap();
|
||||
let mut pkg = zip::ZipArchive::new(std::io::Cursor::new(xml)).unwrap();
|
||||
let mut doc_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut pkg.by_name("word/document.xml").unwrap(), &mut doc_xml).unwrap();
|
||||
let doc_xml = doc_xml
|
||||
.replace("prst=\"textArchUp\"", "prst=\"textNotAWarp\"")
|
||||
.replace("a:stCxn id=\"5\" idx=\"3\"", "a:stCxn id=\"404\" idx=\"99999999999\"");
|
||||
let bytes = replace_part(&wordcraft_docx::write(&r).unwrap(), "word/document.xml", doc_xml.as_bytes());
|
||||
let h = wordcraft_docx::read(&bytes).unwrap();
|
||||
let got = ¶s(&h)[0].objects;
|
||||
assert_eq!(extra(&got[0]).warp, None);
|
||||
assert_eq!(extra(&got[1]).start, Some(ConnEnd { id: 404, site: wordcraft_doc::connector::MAX_SITE }));
|
||||
}
|
||||
|
||||
/// `zip` with part `name` replaced by `data`.
|
||||
fn replace_part(zip_bytes: &[u8], name: &str, data: &[u8]) -> Vec<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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}))
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
@ -164,3 +164,5 @@ mod tests;
|
|||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_typing;
|
||||
#[cfg(test)]
|
||||
mod tests_wordart;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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"}));
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 { .. } => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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, .. } => {
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue