mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 17:02:08 -06:00
Merge pull request #789 from vangel76/feat/wordart-784
Insert: WordArt (insert.wordArt) (#784)
This commit is contained in:
@@ -199,6 +199,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-12 | Insert › Text › WordArt (`insert.wordArt`, `wordArt.set`): floating decorative text with 10 original styles, outline and shadow, in-place editing, XLSX and print (#784) |
|
||||
| 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) |
|
||||
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
|
||||
| 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) |
|
||||
@@ -313,6 +314,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-12 | minor | Catalog 276 → 277 / 292: Insert › WordArt (#784) |
|
||||
| 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row |
|
||||
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod spelling;
|
||||
pub mod stash;
|
||||
pub mod view;
|
||||
pub mod whatif;
|
||||
pub mod wordart;
|
||||
|
||||
use gridcraft_calc::Key;
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(extra::specs());
|
||||
v.extend(macros::specs());
|
||||
v.extend(draw::specs());
|
||||
v.extend(wordart::specs());
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
|
||||
@@ -1222,9 +1222,15 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
if shape.kind != ShapeKind::TextBox {
|
||||
if !matches!(shape.kind, ShapeKind::TextBox | ShapeKind::WordArt) {
|
||||
return Err(bad("shape.setText", "the shape is not a text box"));
|
||||
}
|
||||
if shape.kind == ShapeKind::WordArt && text.is_empty() {
|
||||
return Err(bad("shape.setText", "WordArt text must not be empty"));
|
||||
}
|
||||
if shape.kind == ShapeKind::WordArt && text.chars().nth(super::wordart::MAX_CHARS).is_some() {
|
||||
return Err(bad("shape.setText", format!("`text` is longer than {} characters", super::wordart::MAX_CHARS)));
|
||||
}
|
||||
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
|
||||
@@ -213,6 +213,21 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl
|
||||
ShapeTextAlign::Center => x + (w - lw) / 2.0,
|
||||
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
|
||||
};
|
||||
// WordArt's drop shadow: the line again, offset, in the shadow colour (under the text).
|
||||
if let Some(sc) = fm.text_shadow.and_then(|c| rgb_of(c, wb)) {
|
||||
let off = (size * 0.06).max(0.5);
|
||||
pg.text(lx + off, yy + off, size, font, sc, l);
|
||||
}
|
||||
// WordArt's text outline, as on screen: the line in the outline colour, offset in eight
|
||||
// directions by half the outline width, under the filled text.
|
||||
if let Some((c, ow)) = fm.text_outline
|
||||
&& let Some(oc) = rgb_of(c, wb)
|
||||
{
|
||||
let d = (ow * b.scale / 2.0).clamp(0.25, 20.0);
|
||||
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
|
||||
pg.text(lx + dx * d, yy + dy * d, size, font, oc, l);
|
||||
}
|
||||
}
|
||||
pg.text(lx, yy, size, font, color, l);
|
||||
yy += size * 1.2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Insert › Text › WordArt: decorative text as a floating shape (`ShapeKind::WordArt`) built on
|
||||
//! the text-box machinery (anchor, move/resize/delete, undo, XLSX shape text body, printing).
|
||||
//! The style palette below is original to GridCraft.
|
||||
|
||||
use gridcraft_model::{Anchor, Color, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok,
|
||||
u32_param,
|
||||
};
|
||||
|
||||
/// One entry of the style gallery.
|
||||
pub struct WordArtStyle {
|
||||
pub name: &'static str,
|
||||
pub fill: Color,
|
||||
pub outline: Option<(Color, f32)>,
|
||||
pub shadow: Option<Color>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// Font size in points.
|
||||
pub size: f32,
|
||||
}
|
||||
|
||||
const fn rgb(v: u32) -> Color {
|
||||
Color::Rgb(v)
|
||||
}
|
||||
|
||||
/// GridCraft's own WordArt presets. Theme colours where it makes sense (they follow the theme),
|
||||
/// fixed colours for the rest.
|
||||
pub const STYLES: [WordArtStyle; 10] = [
|
||||
WordArtStyle { name: "Classic", fill: Color::Theme(4, 0), outline: None, shadow: None, bold: true, italic: false, size: 36.0 },
|
||||
WordArtStyle { name: "Midnight", fill: rgb(0x1B2A49), outline: None, shadow: Some(rgb(0x8A94A6)), bold: true, italic: false, size: 40.0 },
|
||||
WordArtStyle { name: "Sunrise", fill: rgb(0xF2A33A), outline: Some((rgb(0xB5451B), 1.5)), shadow: None, bold: true, italic: false, size: 40.0 },
|
||||
WordArtStyle {
|
||||
name: "Lagoon",
|
||||
fill: rgb(0x1F9E9A),
|
||||
outline: Some((rgb(0x0B4F5C), 1.0)),
|
||||
shadow: Some(rgb(0x9CC9C7)),
|
||||
bold: true,
|
||||
italic: true,
|
||||
size: 36.0,
|
||||
},
|
||||
WordArtStyle { name: "Ember", fill: rgb(0xD64527), outline: None, shadow: Some(rgb(0x5A1A0E)), bold: true, italic: false, size: 44.0 },
|
||||
WordArtStyle {
|
||||
name: "Mint Outline",
|
||||
fill: rgb(0xFFFFFF),
|
||||
outline: Some((rgb(0x2E9B6A), 2.0)),
|
||||
shadow: None,
|
||||
bold: true,
|
||||
italic: false,
|
||||
size: 40.0,
|
||||
},
|
||||
WordArtStyle { name: "Ink Stamp", fill: rgb(0x2F3A8F), outline: Some((rgb(0x2F3A8F), 0.5)), shadow: None, bold: true, italic: false, size: 32.0 },
|
||||
WordArtStyle { name: "Orchid", fill: rgb(0x8E4FB5), outline: None, shadow: Some(rgb(0xD9C2E8)), bold: false, italic: true, size: 40.0 },
|
||||
WordArtStyle {
|
||||
name: "Graphite",
|
||||
fill: rgb(0x4A4F57),
|
||||
outline: Some((rgb(0xFFFFFF), 1.0)),
|
||||
shadow: Some(rgb(0x9AA0A8)),
|
||||
bold: true,
|
||||
italic: false,
|
||||
size: 36.0,
|
||||
},
|
||||
WordArtStyle {
|
||||
name: "Lime Pop",
|
||||
fill: rgb(0xA6D61C),
|
||||
outline: Some((rgb(0x3F5A00), 1.5)),
|
||||
shadow: Some(rgb(0x6E8B2C)),
|
||||
bold: true,
|
||||
italic: true,
|
||||
size: 40.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// The text of a WordArt inserted without `text`.
|
||||
pub const DEFAULT_TEXT: &str = "Your text here";
|
||||
/// Longest WordArt text (characters).
|
||||
pub const MAX_CHARS: usize = 1000;
|
||||
const MAX_POINTS: f64 = 400.0;
|
||||
const MAX_SIDE: f64 = 4000.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.wordArt",
|
||||
"WordArt",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{style?: preset name or 0-based index (Classic, Midnight, Sunrise, Lagoon, Ember, Mint Outline, Ink Stamp, Orchid, Graphite, Lime Pop), text?, at?, width?, height?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
|
||||
has_doc,
|
||||
insert_word_art
|
||||
),
|
||||
cmd!(
|
||||
"wordArt.set",
|
||||
"Format WordArt",
|
||||
[],
|
||||
None,
|
||||
"{id: WordArt id, style?, text?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
|
||||
has_doc,
|
||||
set_word_art
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn style_param(command: &str, p: &Json) -> Result<Option<&'static WordArtStyle>> {
|
||||
match p.get("style") {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::Number(n)) => n
|
||||
.as_u64()
|
||||
.and_then(|i| STYLES.get(usize::try_from(i).ok()?))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, format!("`style` index must be 0..{}", STYLES.len() - 1))),
|
||||
Some(Json::String(name)) => STYLES
|
||||
.iter()
|
||||
.find(|s| s.name.eq_ignore_ascii_case(name.trim()))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, format!("unknown WordArt style `{name}`"))),
|
||||
Some(_) => Err(bad(command, "`style` must be a preset name or index")),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_param(command: &str, p: &Json) -> Result<Option<String>> {
|
||||
match p.get("text") {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(t)) if t.is_empty() => Err(bad(command, "`text` must not be empty")),
|
||||
Some(Json::String(t)) if t.chars().nth(MAX_CHARS).is_some() => Err(bad(command, format!("`text` is longer than {MAX_CHARS} characters"))),
|
||||
Some(Json::String(t)) => Ok(Some(t.clone())),
|
||||
Some(_) => Err(bad(command, "`text` must be a string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a preset, then explicit overrides, to a shape's format and fill.
|
||||
fn apply(command: &str, shape: &mut Shape, style: Option<&WordArtStyle>, p: &Json) -> Result<()> {
|
||||
if let Some(st) = style {
|
||||
shape.format.text_color = Some(st.fill);
|
||||
shape.format.text_outline = st.outline;
|
||||
shape.format.text_shadow = st.shadow;
|
||||
shape.format.bold = st.bold;
|
||||
shape.format.italic = st.italic;
|
||||
shape.format.font_size = Some(st.size);
|
||||
}
|
||||
let f = &mut shape.format;
|
||||
if let Some(c) = p.get("fill").filter(|v| !v.is_null()) {
|
||||
f.text_color = Some(super::format::color_param(Some(c)).ok_or_else(|| bad(command, "`fill` must be a hex colour"))?);
|
||||
}
|
||||
match p.get("outline") {
|
||||
None | Some(Json::Null) => {}
|
||||
Some(Json::Bool(false)) => f.text_outline = None,
|
||||
Some(v) => {
|
||||
let c = super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`outline` must be a hex colour or false"))?;
|
||||
let w = f64_param(p, "outlineWidth").map_or(f.text_outline.map_or(1.5, |o| o.1), |w| w.clamp(0.25, 20.0) as f32);
|
||||
f.text_outline = Some((c, w));
|
||||
}
|
||||
}
|
||||
if let (Some(w), Some((c, _))) = (f64_param(p, "outlineWidth"), f.text_outline) {
|
||||
f.text_outline = Some((c, w.clamp(0.25, 20.0) as f32));
|
||||
}
|
||||
match p.get("shadow") {
|
||||
None | Some(Json::Null) => {}
|
||||
Some(Json::Bool(false)) => f.text_shadow = None,
|
||||
Some(Json::Bool(true)) => f.text_shadow = Some(f.text_shadow.unwrap_or(rgb(0x808080))),
|
||||
Some(v) => {
|
||||
f.text_shadow = Some(super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`shadow` must be a hex colour or a boolean"))?)
|
||||
}
|
||||
}
|
||||
if let Some(b) = bool_param(p, "bold") {
|
||||
f.bold = b;
|
||||
}
|
||||
if let Some(b) = bool_param(p, "italic") {
|
||||
f.italic = b;
|
||||
}
|
||||
if let Some(sz) = f64_param(p, "fontSize") {
|
||||
f.font_size = Some(sz.clamp(1.0, MAX_POINTS) as f32);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_word_art(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let style = style_param("insert.wordArt", p)?.unwrap_or(&STYLES[0]);
|
||||
let text = text_param("insert.wordArt", p)?.unwrap_or_else(|| DEFAULT_TEXT.to_string());
|
||||
let d = s.doc()?;
|
||||
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let mut shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::WordArt,
|
||||
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width: 0.0, height: 0.0, mode: anchor_mode_param(p).unwrap_or_default() },
|
||||
fill: Color::Auto,
|
||||
line: Color::Auto,
|
||||
text: text.clone(),
|
||||
format: ShapeFormat { no_line: true, align: Some(ShapeTextAlign::Center), anchor: Some(ShapeTextAnchor::Middle), ..Default::default() },
|
||||
math: Default::default(),
|
||||
};
|
||||
apply("insert.wordArt", &mut shape, Some(style), p)?;
|
||||
// Default box: the longest line at the font size (about 0.62 em per character) plus padding.
|
||||
let pt = f64::from(shape.format.font_size.unwrap_or(36.0));
|
||||
let longest = text.split('\n').map(|l| l.chars().count()).max().unwrap_or(1) as f64;
|
||||
let lines = text.split('\n').count() as f64;
|
||||
let width = f64_param(p, "width").unwrap_or(longest * pt * 0.62 * 96.0 / 72.0 + 24.0).clamp(8.0, MAX_SIDE);
|
||||
let height = f64_param(p, "height").unwrap_or(lines * pt * 1.35 * 96.0 / 72.0 + 12.0).clamp(8.0, MAX_SIDE);
|
||||
shape.anchor.width = width as f32;
|
||||
shape.anchor.height = height as f32;
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"shape": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_word_art(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("wordArt.set", "`id` must be a WordArt id"))?;
|
||||
let style = style_param("wordArt.set", p)?;
|
||||
let text = text_param("wordArt.set", p)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
let current = sh.shapes.iter().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
if current.kind != ShapeKind::WordArt {
|
||||
return Err(bad("wordArt.set", "the shape is not WordArt"));
|
||||
}
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let mut updated = current.clone();
|
||||
apply("wordArt.set", &mut updated, style, p)?;
|
||||
if let Some(t) = text {
|
||||
updated.text = t;
|
||||
}
|
||||
if &updated == current {
|
||||
return ok();
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
*shape = updated;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -532,6 +532,118 @@ fn shape_text_edit_survives_xlsx_roundtrip() {
|
||||
assert_eq!(shape.text, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_insert_respects_sheet_protection() {
|
||||
let mut s = s();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
let undo_len = s.doc().unwrap().undo.len();
|
||||
assert!(s.execute("insert.wordArt", json!({})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before));
|
||||
assert_eq!(s.doc().unwrap().undo.len(), undo_len);
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("insert.wordArt", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_inserts_with_defaults_and_undoes() {
|
||||
use gridcraft_model::ShapeKind;
|
||||
let mut s = s();
|
||||
// Empty params: default style and text, no dialog.
|
||||
let r = s.execute("insert.wordArt", json!({})).unwrap();
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
let shape = s.doc().unwrap().wb.active().unwrap().shapes[0].clone();
|
||||
assert_eq!(shape.id, r["shape"].as_u64().unwrap() as u32);
|
||||
assert_eq!(shape.kind, ShapeKind::WordArt);
|
||||
assert_eq!(shape.text, crate::cmd::wordart::DEFAULT_TEXT);
|
||||
assert!(shape.anchor.width > 8.0 && shape.format.text_color.is_some());
|
||||
// Named style plus overrides; restyle and edit the text through wordArt.set.
|
||||
s.execute("insert.wordArt", json!({"style": "ember", "text": "Q3", "fontSize": 60, "shadow": false, "outline": "#112233", "outlineWidth": 3}))
|
||||
.unwrap();
|
||||
let ember = s.doc().unwrap().wb.active().unwrap().shapes[1].clone();
|
||||
assert_eq!(ember.format.font_size, Some(60.0));
|
||||
assert_eq!((ember.format.text_shadow, ember.format.text_outline.map(|o| o.1)), (None, Some(3.0)));
|
||||
s.execute("wordArt.set", json!({"id": ember.id, "style": 3, "text": "Q4"})).unwrap();
|
||||
let styled = s.doc().unwrap().wb.active().unwrap().shapes[1].clone();
|
||||
assert_eq!((styled.text.as_str(), styled.format.italic), ("Q4", true));
|
||||
s.execute("shape.setText", json!({"id": ember.id, "text": "Q5"})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1], ember);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1].text, "Q4");
|
||||
// Moves and deletes like any floating shape.
|
||||
s.execute("object.move", json!({"kind": "shape", "id": ember.id, "at": "D4"})).unwrap();
|
||||
s.execute("object.delete", json!({"kind": "shape", "id": ember.id})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_rejects_hostile_params() {
|
||||
let mut s = s();
|
||||
let id = s.execute("insert.wordArt", json!({})).unwrap()["shape"].clone();
|
||||
let rectangle = s.execute("insert.shape", json!({"kind": "rectangle"})).unwrap()["shape"].clone();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
for (cmd, params) in [
|
||||
("insert.wordArt", json!({"style": "No Such Style"})),
|
||||
("insert.wordArt", json!({"style": 99})),
|
||||
("insert.wordArt", json!({"style": -1})),
|
||||
("insert.wordArt", json!({"text": 5})),
|
||||
("insert.wordArt", json!({"text": ""})),
|
||||
("insert.wordArt", json!({"text": "x".repeat(crate::cmd::wordart::MAX_CHARS + 1)})),
|
||||
("insert.wordArt", json!({"fill": "not a colour"})),
|
||||
("wordArt.set", json!({})),
|
||||
("wordArt.set", json!({"id": 999})),
|
||||
("wordArt.set", json!({"id": rectangle})),
|
||||
("shape.setText", json!({"id": id, "text": ""})),
|
||||
] {
|
||||
assert!(matches!(s.execute(cmd, params), Err(crate::EngineError::BadParams { .. })), "{cmd}");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before));
|
||||
}
|
||||
// Absurd sizes are clamped, not trusted.
|
||||
s.execute("insert.wordArt", json!({"fontSize": 1e12, "width": 1e300, "height": -5})).unwrap();
|
||||
let big = &s.doc().unwrap().wb.active().unwrap().shapes[2];
|
||||
assert!(big.anchor.width <= 4000.0 && big.anchor.height >= 8.0 && big.format.font_size == Some(400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_survives_xlsx_roundtrip() {
|
||||
use gridcraft_model::ShapeKind;
|
||||
let mut s = s();
|
||||
s.execute("insert.wordArt", json!({"style": "Lagoon", "text": "Sales <2026>\nOutlook & more"})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().shapes[0].clone();
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
s.execute("file.open", json!({"name": "wordart.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
let after = &s.doc().unwrap().wb.active().unwrap().shapes[0];
|
||||
assert_eq!(after.kind, ShapeKind::WordArt);
|
||||
assert_eq!(after.text, before.text);
|
||||
assert_eq!(after.format.text_color, before.format.text_color);
|
||||
assert_eq!(after.format.text_outline, before.format.text_outline);
|
||||
assert_eq!(after.format.text_shadow, before.format.text_shadow);
|
||||
assert_eq!((after.format.bold, after.format.italic, after.format.font_size), (true, true, before.format.font_size));
|
||||
assert!(after.format.no_line);
|
||||
}
|
||||
|
||||
/// White text with a green outline (Mint Outline) prints its outline, so it is not invisible on a
|
||||
/// white page.
|
||||
#[test]
|
||||
fn word_art_outline_prints() {
|
||||
let mut s = s();
|
||||
s.execute("insert.wordArt", json!({"style": "Mint Outline", "text": "VISIBLE", "at": "A1"})).unwrap();
|
||||
let pdf = s.execute("file.exportPdf", json!({"range": "A1:G10"})).unwrap();
|
||||
let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let outline = text.matches("0.18 0.608 0.416 rg").count();
|
||||
assert!(outline >= 1, "the outline colour (2E9B6A) is drawn");
|
||||
assert_eq!(text.matches("(VISIBLE) Tj").count(), 9, "eight outline copies under the white text");
|
||||
assert!(text.contains("1 1 1 rg"), "the white fill is drawn on top");
|
||||
// The last copy drawn is the filled text, in white; every outline copy comes before it.
|
||||
let before_fill = &text[..text.rfind("(VISIBLE) Tj").unwrap()];
|
||||
assert!(before_fill.rfind("1 1 1 rg") > before_fill.rfind("0.18 0.608 0.416 rg"), "the white fill is drawn last");
|
||||
assert_eq!(before_fill.matches("(VISIBLE) Tj").count(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_border_clears_only_shared_edges_and_undo_restores_them() {
|
||||
use gridcraft_core::RangeRef;
|
||||
|
||||
@@ -668,6 +668,9 @@ pub enum ShapeKind {
|
||||
/// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the
|
||||
/// anchor (sheet points), `Shape::line` the ink colour and `fill` unused.
|
||||
Ink,
|
||||
/// Decorative text (`insert.wordArt`): a transparent shape whose `Shape::text` is drawn with
|
||||
/// the large, filled/outlined/shadowed text properties of `Shape::format`.
|
||||
WordArt,
|
||||
/// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour
|
||||
/// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout.
|
||||
Equation,
|
||||
@@ -1026,6 +1029,8 @@ pub struct ShapeFormat {
|
||||
pub text_color: Option<Color>,
|
||||
/// Text outline (`a:rPr/a:ln`): colour and width in points.
|
||||
pub text_outline: Option<(Color, f32)>,
|
||||
/// Text drop shadow colour (`a:rPr/a:effectLst/a:outerShdw`); `None` is no shadow.
|
||||
pub text_shadow: Option<Color>,
|
||||
/// `None`: the shape's default (left for text boxes, centred for other shapes).
|
||||
pub align: Option<ShapeTextAlign>,
|
||||
/// `None`: the shape's default (top for text boxes, middle for other shapes).
|
||||
|
||||
@@ -198,10 +198,11 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh {
|
||||
fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Shape, r: Rect, z: f32) {
|
||||
use gridcraft_engine::model::{ShapeTextAlign, ShapeTextAnchor};
|
||||
let f = &sp.format;
|
||||
let word_art = sp.kind == ShapeKind::WordArt;
|
||||
let text_box = sp.kind == ShapeKind::TextBox;
|
||||
let align = f.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
|
||||
let anchor = f.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
|
||||
let default_color = if text_box { Color32::BLACK } else { Color32::WHITE };
|
||||
let default_color = if text_box || word_art { Color32::BLACK } else { Color32::WHITE };
|
||||
let color = f.text_color.and_then(|c| c.resolve(&wb.theme)).map(theme::color32).unwrap_or(default_color);
|
||||
// Default 14 px (as before); a set size is in points.
|
||||
let size = f.font_size.map_or(14.0, |pt| pt * 96.0 / 72.0).clamp(1.0, 2000.0) * z;
|
||||
@@ -227,6 +228,11 @@ fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Sh
|
||||
};
|
||||
let pos = pos2(x, y);
|
||||
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
|
||||
// WordArt's drop shadow: the text again, offset down and right, under everything else.
|
||||
if let Some(sc) = f.text_shadow.and_then(|c| c.resolve(&wb.theme)).map(theme::color32) {
|
||||
let d = (size * 0.05).clamp(1.0, 24.0);
|
||||
clip.galley_with_override_text_color(pos + vec2(d, d), g.clone(), sc);
|
||||
}
|
||||
// egui has no stroked text: the outline is the text drawn in the outline colour, offset in
|
||||
// eight directions by the outline width, under the filled text.
|
||||
if let Some((c, w)) = f.text_outline
|
||||
@@ -295,6 +301,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
let line =
|
||||
if sp.format.no_line { Color32::TRANSPARENT } else { sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY) };
|
||||
match sp.kind {
|
||||
// WordArt is only its text (no fill or outline of its own).
|
||||
ShapeKind::WordArt => {}
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox => {
|
||||
p.rect_filled(r, 0.0, fill);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, line), StrokeKind::Inside);
|
||||
|
||||
@@ -103,6 +103,7 @@ pub enum Icon {
|
||||
Comment,
|
||||
Note,
|
||||
TextBox,
|
||||
WordArt,
|
||||
Symbol,
|
||||
/// An equation: a radical sign over an italic x.
|
||||
Equation,
|
||||
@@ -631,6 +632,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c));
|
||||
g.text(8.0, 8.0, "A", 8.0, c, (false, false));
|
||||
}
|
||||
Icon::WordArt => {
|
||||
g.text(9.0, 9.0, "A", 13.0, Color32::from_rgb(0x8E, 0x4F, 0xB5), (true, false));
|
||||
g.text(8.0, 8.0, "A", 13.0, c, (true, false));
|
||||
}
|
||||
Icon::Symbol => g.text(8.0, 8.0, "Ω", 12.0, c, (false, false)),
|
||||
Icon::Equation => {
|
||||
g.line(&[(1.5, 9.0), (3.5, 8.0), (5.5, 13.5), (8.5, 2.5), (14.5, 2.5)], c, 1.2);
|
||||
|
||||
@@ -194,7 +194,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("NU", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Symbols; containment(Symbol)
|
||||
("NZS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Excel: Symbol (modern)
|
||||
("NV", Entry::Finish(Action::Command("insert.pivotTable"))), // Excel: PivotTable
|
||||
("NW", Entry::Finish(Action::Pending("insert.wordArt"))), // pending; Excel: WordArt; WordArt; reserved until the command lands
|
||||
("NW", Entry::Finish(Action::Command("insert.wordArt"))), // Excel: WordArt
|
||||
("NX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
|
||||
("NZK", Entry::Continue), // Excel: Sparklines Menu; group level:
|
||||
("NZKSL", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Line Sparkline
|
||||
|
||||
@@ -1336,9 +1336,7 @@
|
||||
"seq": "NW",
|
||||
"kind": "command",
|
||||
"target": "insert.wordArt",
|
||||
"pending": true,
|
||||
"excel": "WordArt",
|
||||
"note": "WordArt; reserved until the command lands"
|
||||
"excel": "WordArt"
|
||||
},
|
||||
{
|
||||
"seq": "NX",
|
||||
|
||||
@@ -59,6 +59,7 @@ pub mod theme;
|
||||
mod updates_ui;
|
||||
pub mod widgets;
|
||||
mod window_ui;
|
||||
pub mod wordart;
|
||||
|
||||
pub use window_ui::WindowUi;
|
||||
|
||||
|
||||
@@ -128,7 +128,9 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction {
|
||||
match o.kind {
|
||||
ObjKind::Chart => OpenAction::FormatChart(o.id),
|
||||
ObjKind::Equation => OpenAction::EditEquation(o.id),
|
||||
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id),
|
||||
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) => {
|
||||
OpenAction::EditText(o.id)
|
||||
}
|
||||
ObjKind::Control => OpenAction::EditControl(o.id),
|
||||
ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select,
|
||||
}
|
||||
|
||||
@@ -208,6 +208,12 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if __k.clicked() {
|
||||
act(app, "insert.textBox", json!({}));
|
||||
}
|
||||
// Two lines like its neighbours, so the Insert tab stays as wide as before; the translated label is split, the catalog key is one line.
|
||||
let label = app.ui.language.tr("Decorative Text").replacen(' ', "\n", 1);
|
||||
let wa = big_button(ui, Icon::WordArt, &label, "Decorative Text", true);
|
||||
anchor_keytip(app, ui, &wa, &["insert.wordArt"]);
|
||||
// Menu invocation shows the style gallery; the command itself never opens a dialog.
|
||||
egui::Popup::menu(&wa).show(|ui| crate::wordart::gallery(app, ui));
|
||||
let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false);
|
||||
control_keytip(app, ui, &__k, "dialog:headerFooter");
|
||||
if __k.clicked() {
|
||||
|
||||
@@ -33,7 +33,7 @@ impl SheetApp {
|
||||
}
|
||||
let Some(d) = self.session.active() else { return false };
|
||||
let Some(sh) = d.wb.active() else { return false };
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox) else {
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else {
|
||||
return false;
|
||||
};
|
||||
// shape.setText refuses edits on a protected sheet; don't open an editor that can't commit.
|
||||
@@ -118,7 +118,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
|
||||
app.text_box_editor = Some(ed);
|
||||
return;
|
||||
}
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && s.kind == ShapeKind::TextBox) else {
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else {
|
||||
app.session.mode = Mode::Ready;
|
||||
return;
|
||||
};
|
||||
@@ -153,7 +153,15 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
|
||||
.add(
|
||||
TextEdit::multiline(&mut ed.text)
|
||||
.id(id)
|
||||
.font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())))
|
||||
.font(FontId::new(
|
||||
// WordArt is edited at its own size (clamped so the box stays usable).
|
||||
if shape.kind == ShapeKind::WordArt {
|
||||
shape.format.font_size.map_or(14.0, |pt| (pt * 96.0 / 72.0).clamp(8.0, 200.0))
|
||||
} else {
|
||||
14.0
|
||||
} * geo.z,
|
||||
egui::FontFamily::Name(theme::CELL.into()),
|
||||
))
|
||||
.text_color(Color32::BLACK)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(egui::Margin::ZERO)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Insert › Text › WordArt: the style gallery. Previews are drawn in code from the engine's
|
||||
//! preset palette (`gridcraft_engine::cmd::wordart::STYLES`); picking one runs `insert.wordArt`.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Painter, Rect, Sense, Ui, vec2};
|
||||
use gridcraft_engine::cmd::wordart::{STYLES, WordArtStyle};
|
||||
use gridcraft_engine::model::Theme;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{SheetApp, theme};
|
||||
|
||||
/// Paints "Abc" in `style` centred in `r` (fill, outline and shadow as the sheet object has them).
|
||||
pub(crate) fn paint_sample(p: &Painter, r: Rect, style: &WordArtStyle, theme: &Theme) {
|
||||
let px = 24.0;
|
||||
let family = theme::cell_family("", style.bold, style.italic);
|
||||
let font = FontId::new(px, family);
|
||||
let c = r.center();
|
||||
let col = |c: gridcraft_engine::model::Color| c.resolve(theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||
if let Some(sh) = style.shadow {
|
||||
p.text(c + vec2(1.5, 1.5), Align2::CENTER_CENTER, "Abc", font.clone(), col(sh));
|
||||
}
|
||||
if let Some((oc, w)) = style.outline {
|
||||
let d = (w * 96.0 / 72.0 / 2.0).clamp(0.5, 3.0);
|
||||
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
|
||||
p.text(c + vec2(dx * d, dy * d), Align2::CENTER_CENTER, "Abc", font.clone(), col(oc));
|
||||
}
|
||||
}
|
||||
p.text(c, Align2::CENTER_CENTER, "Abc", font, col(style.fill));
|
||||
}
|
||||
|
||||
/// The gallery popup content: two rows of previews; a click inserts that style.
|
||||
pub(crate) fn gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
|
||||
ui.set_width(5.0 * 72.0);
|
||||
egui::Grid::new("wordart_gallery").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
for (i, style) in STYLES.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(68.0, 44.0), Sense::click());
|
||||
let t = theme::Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { Color32::WHITE });
|
||||
ui.painter().rect_stroke(r, 4.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
paint_sample(&ui.painter().with_clip_rect(r), r, style, &theme);
|
||||
if resp.on_hover_text(style.name).clicked() {
|
||||
app.run_or_alert("insert.wordArt", json!({"style": style.name}));
|
||||
ui.close();
|
||||
}
|
||||
if (i + 1) % 5 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1417,10 +1417,7 @@ fn keytip_nv_() {
|
||||
fn keytip_nw_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "NW", false);
|
||||
assert!(h.state().dialog.is_none(), "NW must not open a dialog");
|
||||
assert!(h.state().message.is_none(), "NW must not open a message box");
|
||||
assert!(h.state().toast.is_none(), "NW must not raise a toast");
|
||||
assert!(h.state().session.journal.is_empty(), "NW must not journal");
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.wordArt"), "NW must journal insert.wordArt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -495,7 +495,7 @@ fn vertical_alignment_keytips_run_their_commands() {
|
||||
fn pending_sequences_are_silent_no_ops() {
|
||||
let mut h = harness(blank());
|
||||
let journal = h.state().session.journal.len();
|
||||
sequence(&mut h, &[Key::N, Key::W], false); // NW -> insert.wordArt (missing)
|
||||
sequence(&mut h, &[Key::N, Key::S, Key::C], false); // NSC -> insert.screenshot (missing)
|
||||
assert!(h.state().dialog.is_none(), "no dialog for a pending sequence");
|
||||
assert!(h.state().message.is_none(), "no message box for a pending sequence");
|
||||
assert!(h.state().toast.is_none(), "no toast for a pending sequence");
|
||||
|
||||
@@ -221,7 +221,16 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
|
||||
let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false));
|
||||
let ln = sp.child("ln");
|
||||
let arrow = ln.is_some_and(|l| l.child("tailEnd").or_else(|| l.child("headEnd")).is_some_and(|t| t.attr("type").is_some_and(|v| v != "none")));
|
||||
// WordArt: our marker, a text body with the non-warping text warp `textNoShape`, on a rectangle
|
||||
// that is a text box (as the writer saves it) or has no fill and no outline of its own (as
|
||||
// WordArt usually comes). `textNoShape` is the standard "no warp" value, so on an ordinary
|
||||
// shape (an ellipse, a filled rectangle) it, like any other warp, leaves the shape what its
|
||||
// geometry says.
|
||||
let word_art = prst == "rect"
|
||||
&& (tx_box || (sp.child("noFill").is_some() && !ln.is_some_and(|l| l.child("solidFill").is_some())))
|
||||
&& obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some_and(|w| w.attr("prst") == Some("textNoShape"));
|
||||
let kind = match prst {
|
||||
_ if word_art => ShapeKind::WordArt,
|
||||
_ if tx_box => ShapeKind::TextBox,
|
||||
"roundRect" => ShapeKind::RoundedRectangle,
|
||||
"ellipse" => ShapeKind::Ellipse,
|
||||
@@ -294,6 +303,7 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat {
|
||||
f.italic = r.flag("i", false);
|
||||
f.font = r.child("latin").and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty() && !t.starts_with('+')).map(str::to_string);
|
||||
f.text_color = r.child("solidFill").and_then(dml_color);
|
||||
f.text_shadow = r.child("effectLst").and_then(|e| e.child("outerShdw")).and_then(dml_color);
|
||||
f.text_outline = r.child("ln").and_then(|l| {
|
||||
let c = l.child("solidFill").and_then(dml_color)?;
|
||||
// Width in EMU (12700 per point); Excel's default hairline is 0.75 pt.
|
||||
@@ -520,7 +530,7 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
||||
ShapeKind::Triangle => ("triangle", false),
|
||||
ShapeKind::Line | ShapeKind::Arrow => ("line", false),
|
||||
// Equations are written by `write_equation`; this arm keeps the match whole.
|
||||
ShapeKind::TextBox | ShapeKind::Equation => ("rect", true),
|
||||
ShapeKind::TextBox | ShapeKind::WordArt | ShapeKind::Equation => ("rect", true),
|
||||
ShapeKind::Icon | ShapeKind::Ink => ("rect", false),
|
||||
};
|
||||
let _ = write!(
|
||||
@@ -550,7 +560,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
||||
s.push_str("</a:ln>");
|
||||
}
|
||||
s.push_str("</xdr:spPr>");
|
||||
if !sh.text.is_empty() {
|
||||
// A WordArt with no text still writes its body, which carries the WordArt marker.
|
||||
if !sh.text.is_empty() || sh.kind == ShapeKind::WordArt {
|
||||
let f = &sh.format;
|
||||
let anchor = match f.anchor {
|
||||
Some(ShapeTextAnchor::Top) => " anchor=\"t\"",
|
||||
@@ -559,7 +570,15 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
||||
None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"",
|
||||
None => "",
|
||||
};
|
||||
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
|
||||
if sh.kind == ShapeKind::WordArt {
|
||||
// Our WordArt marker: a (non-warping) text warp, which the reader maps back to WordArt.
|
||||
let _ = write!(
|
||||
s,
|
||||
"<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}><a:prstTxWarp prst=\"textNoShape\"><a:avLst/></a:prstTxWarp></a:bodyPr><a:lstStyle/>"
|
||||
);
|
||||
} else {
|
||||
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
|
||||
}
|
||||
let algn = match f.align {
|
||||
None => "",
|
||||
Some(ShapeTextAlign::Left) => " algn=\"l\"",
|
||||
@@ -614,6 +633,12 @@ fn run_props(f: &ShapeFormat, theme: &Theme) -> (String, String) {
|
||||
if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) {
|
||||
let _ = write!(body, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
if let Some(c) = f.text_shadow.as_ref().and_then(|c| srgb(c, theme)) {
|
||||
let _ = write!(
|
||||
body,
|
||||
"<a:effectLst><a:outerShdw blurRad=\"38100\" dist=\"38100\" dir=\"2700000\" algn=\"tl\" rotWithShape=\"0\">{c}</a:outerShdw></a:effectLst>"
|
||||
);
|
||||
}
|
||||
if let Some(font) = &f.font {
|
||||
let _ = write!(body, "<a:latin typeface=\"{}\"/>", esc_attr(font));
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ fn sample() -> Workbook {
|
||||
font: Some("Arial".into()),
|
||||
text_color: Some(Color::Rgb(0x112233)),
|
||||
text_outline: Some((Color::Rgb(0xFF0000), 1.5)),
|
||||
text_shadow: Some(Color::Rgb(0x445566)),
|
||||
align: Some(ShapeTextAlign::Right),
|
||||
anchor: Some(ShapeTextAnchor::Bottom),
|
||||
rtl: true,
|
||||
|
||||
@@ -86,6 +86,72 @@ fn shape_text_without_rtl_writes_unchanged_ppr() {
|
||||
assert!(!xml.contains("<a:pPr"), "no paragraph properties without alignment or direction: {xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_keeps_right_to_left_paragraphs() {
|
||||
let mut wb = text_box("مرحبا\nبالعالم", ShapeFormat { rtl: true, align: Some(ShapeTextAlign::Right), ..Default::default() });
|
||||
wb.sheet_mut(0).unwrap().shapes[0].kind = ShapeKind::WordArt;
|
||||
let xml = written_drawing(&wb);
|
||||
assert!(xml.contains(r#"<a:prstTxWarp prst="textNoShape">"#));
|
||||
assert_eq!(xml.matches(r#"<a:pPr algn="r" rtl="1"/>"#).count(), 2);
|
||||
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
let shape = &reopened.sheet(0).unwrap().shapes[0];
|
||||
assert_eq!(shape.kind, ShapeKind::WordArt);
|
||||
assert!(shape.format.rtl);
|
||||
assert_eq!(shape.format.align, Some(ShapeTextAlign::Right));
|
||||
assert_eq!(shape.text, "مرحبا\nبالعالم");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_with_empty_text_keeps_its_kind() {
|
||||
let mut wb = text_box("", ShapeFormat::default());
|
||||
wb.sheet_mut(0).unwrap().shapes[0].kind = ShapeKind::WordArt;
|
||||
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_warp_on_a_filled_ellipse_does_not_make_it_word_art() {
|
||||
let drawing = ARABIC_TEXT_BOX
|
||||
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
|
||||
.replace(r#"prst="rect""#, r#"prst="ellipse""#)
|
||||
.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textTriangle"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
|
||||
let wb = read_drawing(&drawing);
|
||||
assert_eq!(wb.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse);
|
||||
}
|
||||
|
||||
/// `textNoShape` is the standard "no text warp" value, so on its own it does not make an ordinary
|
||||
/// shape WordArt: a filled ellipse that carries it reads and saves as the same filled ellipse.
|
||||
#[test]
|
||||
fn an_explicit_no_warp_on_a_filled_ellipse_stays_an_ellipse() {
|
||||
let drawing = ARABIC_TEXT_BOX
|
||||
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
|
||||
.replace(
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
|
||||
r#"<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
|
||||
)
|
||||
.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
|
||||
let wb = read_drawing(&drawing);
|
||||
let shape = &wb.sheet(0).unwrap().shapes[0];
|
||||
assert_eq!(shape.kind, ShapeKind::Ellipse);
|
||||
assert_eq!(shape.fill, Color::Rgb(0xFF0000));
|
||||
let xml = written_drawing(&wb);
|
||||
assert!(xml.contains(r#"<a:prstGeom prst="ellipse">"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#), "{xml}");
|
||||
assert!(!xml.contains(r#"txBox="1""#), "{xml}");
|
||||
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse);
|
||||
// The same warp on a filled rectangle that is not a text box keeps it a rectangle too.
|
||||
let rect = drawing.replace(r#"prst="ellipse""#, r#"prst="rect""#);
|
||||
assert_eq!(read_drawing(&rect).sheet(0).unwrap().shapes[0].kind, ShapeKind::Rectangle);
|
||||
// WordArt as written (a text box) or as a fill-less, line-less rectangle still reads as WordArt.
|
||||
let text_box = ARABIC_TEXT_BOX.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
|
||||
assert_eq!(read_drawing(&text_box).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
|
||||
let bare = text_box
|
||||
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
|
||||
.replace(r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/>"#);
|
||||
assert_eq!(read_drawing(&bare).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
|
||||
}
|
||||
|
||||
/// #2170: a shape font typeface holding `"`, `<` or `&` is escaped as an attribute, so it can't
|
||||
/// close the `typeface` attribute early and inject others; it survives a save and reopen.
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
### 17. Insert objects (F)
|
||||
- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their
|
||||
identity/artwork on save (#551); native Office InkML import is not implemented.
|
||||
- SmartArt, WordArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170;
|
||||
- SmartArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170;
|
||||
pictures in cells (#61, PR #105). Equations exist (LaTeX source with a live preview, Office Math in xlsx); there is
|
||||
no structured (WYSIWYG) equation editing and no ink equations. **Estimate:** 25–40 h.
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**Overall: 281 / 296 commands implemented (94.9%).**
|
||||
**Overall: 282 / 296 commands implemented (95.3%).**
|
||||
|
||||
| Tab | Implemented | Total | Parity |
|
||||
|---|---|---|---|
|
||||
| File | 11 | 11 | 100% |
|
||||
| Home | 94 | 94 | 100% |
|
||||
| Insert | 18 | 25 | 72% |
|
||||
| Insert | 19 | 25 | 76% |
|
||||
| Draw | 4 | 4 | 100% |
|
||||
| Page Layout | 23 | 23 | 100% |
|
||||
| Formulas | 23 | 23 | 100% |
|
||||
@@ -140,7 +140,7 @@ Implemented:
|
||||
- `edit.goToSpecial` Go To Special (Editing)
|
||||
- `home.addIns` Add-ins (Add-ins)
|
||||
|
||||
## Insert (18 / 25, 72%)
|
||||
## Insert (19 / 25, 76%)
|
||||
|
||||
Implemented:
|
||||
|
||||
@@ -159,6 +159,7 @@ Implemented:
|
||||
- `review.newComment` Comment (Comments)
|
||||
- `insert.textBox` Text Box (Text)
|
||||
- `pageLayout.headerFooter` Header & Footer (Text)
|
||||
- `insert.wordArt` Decorative Text (Text)
|
||||
- `insert.equation` Equation (Symbols)
|
||||
- `insert.symbol` Symbol (Symbols)
|
||||
- `insert.checkbox` Checkbox (Cell Controls)
|
||||
@@ -169,7 +170,6 @@ Missing:
|
||||
- `insert.screenshot` Screenshot (Illustrations)
|
||||
- `insert.map` Maps (Charts)
|
||||
- `insert.timeline` Timeline (Filters)
|
||||
- `insert.wordArt` Decorative Text (Text)
|
||||
- `insert.signatureLine` Signature Line (Text)
|
||||
- `insert.object` Object (Text)
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
|
||||
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
|
||||
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer, Sensitivity and Limits reports; Load/Save Model, Reset All and Solver Options; no Population report), Forecast Sheet. Missing: Analyze Data |
|
||||
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
|
||||
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, WordArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
|
||||
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, WordArt (10 own styles; no warps or gradient fills), own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
|
||||
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
|
||||
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog, a Ctrl+Shift shortcut or a form button (which can also run one command). VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility |
|
||||
| **Weighted** | 100 | **~75%** | **~59%** | **300–485** | |
|
||||
|
||||
Reference in New Issue
Block a user