From 470968fcdcdac7378966e4209faa4fbeff0e55e1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:38:11 +0200 Subject: [PATCH 01/16] Insert: WordArt (insert.wordArt) (#784) Floating decorative text built on the text-box shape path: ShapeKind::WordArt, ten original styles, outline/shadow, wordArt.set, gallery in the ribbon, XLSX and print. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/insert.rs | 5 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/print.rs | 5 + crates/engine/src/cmd/wordart.rs | 234 +++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 79 ++++++++++ crates/model/src/features.rs | 5 + crates/ui-egui/src/chartview.rs | 12 +- crates/ui-egui/src/grid.rs | 4 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/icons.rs | 5 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 4 + crates/ui-egui/src/text_box.rs | 14 +- crates/ui-egui/src/wordart.rs | 50 ++++++ crates/xlsx/src/drawing.rs | 22 ++- crates/xlsx/src/tests/roundtrip.rs | 1 + docs/gaps.md | 2 +- docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 4 +- docs/ui-parity.md | 2 +- 29 files changed, 463 insertions(+), 18 deletions(-) create mode 100644 crates/engine/src/cmd/wordart.rs create mode 100644 crates/ui-egui/src/wordart.rs diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..6de6ea579 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 275 / 290 (94.8%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 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 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Catalog 274 → 275 / 290: Insert › WordArt (#784) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 392c5eebf..2e2547e5f 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1048,9 +1048,12 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { 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")); + } // Sheet protection locks drawing objects (there is no "edit objects" allowance yet). if sh.is_protected() { return Err(EngineError::Other(PROTECTED.into())); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..2f9a5c333 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ pub mod solver; pub mod spelling; pub mod view; pub mod whatif; +pub mod wordart; use gridcraft_calc::Key; use gridcraft_core::{CellRef, RangeRef}; @@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(inspect::specs()); v.extend(extra::specs()); v.extend(draw::specs()); + v.extend(wordart::specs()); v.extend(spelling::specs()); v.extend(solver::specs()); v.extend(pivot::specs()); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 986351ec5..08b21d1dc 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1316,6 +1316,11 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) 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); + } pg.text(lx, yy, size, font, color, l); yy += size * 1.2; } diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs new file mode 100644 index 000000000..f300e16a9 --- /dev/null +++ b/crates/engine/src/cmd/wordart.rs @@ -0,0 +1,234 @@ +//! 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::*; +use serde_json::{Value as Json, json}; + +use super::*; + +/// One entry of the style gallery. +pub struct WordArtStyle { + pub name: &'static str, + pub fill: Color, + pub outline: Option<(Color, f32)>, + pub shadow: Option, + 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 { + 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> { + 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> { + 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 { + 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()?; + 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() }, + }; + 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 { + 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) + }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc113e..7518263d8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -357,6 +357,85 @@ fn shape_text_edit_survives_xlsx_roundtrip() { assert_eq!(shape.text, text); } +#[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); +} + #[test] fn no_border_clears_only_shared_edges_and_undo_restores_them() { use gridcraft_core::RangeRef; diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index e04db25cd..3b0f25de0 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -502,6 +502,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, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -552,6 +555,8 @@ pub struct ShapeFormat { pub text_color: Option, /// 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, /// `None`: the shape's default (left for text boxes, centred for other shapes). pub align: Option, /// `None`: the shape's default (top for text boxes, middle for other shapes). diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 180f15c21..e83ebfde3 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -201,10 +201,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; @@ -230,6 +231,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 @@ -272,6 +278,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); @@ -321,7 +329,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) { paint_shape_text(p, wb, sp, r, geo.z); } - if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) { + if label && matches!(sp.kind, ShapeKind::TextBox | ShapeKind::WordArt) && app.selected_chart == Some(sp.id) { selection_frame(p, r); } done(start, r); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef8566..5898390ce 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1696,7 +1696,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.selected_chart = Some(id); if kind == "shape" && resp.double_clicked() - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) + && sh.shapes.iter().any(|s| { + s.id == id && matches!(s.kind, gridcraft_engine::model::ShapeKind::TextBox | gridcraft_engine::model::ShapeKind::WordArt) + }) { app.begin_text_box_edit(id); return; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3dd5..fd2645a9d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -272,6 +272,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Shapes", "図形"), ("Icons", "アイコン"), ("Text\nBox", "テキスト ボックス"), + ("Word\nArt", "ワードアート"), + ("Insert WordArt", "ワードアートの挿入"), ("Comment", "コメント"), ("New\nComment", "新しいコメント"), ("New Note", "新しいメモ"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e55942..adef57bf1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -100,6 +100,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Obrazce", "Icons" => "Ikony", "Text\nBox" => "Textové\npole", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Vložit WordArt", "Comment" => "Komentář", "New\nComment" => "Nový\nkomentář", "New Note" => "Nová poznámka", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2ad37f675..9633259f3 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -100,6 +100,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formen", "Icons" => "Symbole", "Text\nBox" => "Text-\nfeld", + "Word\nArt" => "WordArt", + "Insert WordArt" => "WordArt einfügen", "Comment" => "Kommentar", "New\nComment" => "Neuer\nKommentar", "New Note" => "Neue Notiz", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index da41bd711..d71a89796 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -101,6 +101,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formas", "Icons" => "Iconos", "Text\nBox" => "Cuadro\nde texto", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Insertar WordArt", "Comment" => "Comentario", "New\nComment" => "Nuevo\ncomentario", "New Note" => "Nueva nota", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 7bf9fd100..ed3685d8b 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -106,6 +106,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formes", "Icons" => "Icônes", "Text\nBox" => "Zone de texte", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Insérer un WordArt", "Comment" => "Commentaire", "New\nComment" => "Nouveau commentaire", "New Note" => "Nouvelle note", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b684c7886..0339e8704 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -103,6 +103,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "도형", "Icons" => "아이콘", "Text\nBox" => "텍스트\n상자", + "Word\nArt" => "워드\n아트", + "Insert WordArt" => "워드아트 삽입", "Comment" => "댓글", "New\nComment" => "새 댓글", "New Note" => "새 메모", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef977..0b8fa90e0 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -104,6 +104,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formas", "Icons" => "Ícones", "Text\nBox" => "Caixa\nde Texto", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Inserir WordArt", "Comment" => "Comentário", "New\nComment" => "Novo\nComentário", "New Note" => "Nova Nota", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445d8..a6f332c09 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -103,6 +103,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Фигуры", "Icons" => "Значки", "Text\nBox" => "Текстовое\nполе", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Вставить WordArt", "Comment" => "Комментарий", "New\nComment" => "Новый\nкомментарий", "New Note" => "Новая заметка", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d1cf704cb..dd1ee53fc 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -103,6 +103,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "形状", "Icons" => "图标", "Text\nBox" => "文本\n框", + "Word\nArt" => "艺术字", + "Insert WordArt" => "插入艺术字", "Comment" => "批注", "New\nComment" => "新建\n批注", "New Note" => "新建备注", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fe9f7bf51..a4fd9c5f1 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -78,6 +78,7 @@ pub enum Icon { Comment, Note, TextBox, + WordArt, Symbol, Sparkline, Checkbox, @@ -514,6 +515,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { 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::Function => g.text(8.0, 8.0, "fx", 11.0, c, false, true), Icon::Name => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b19b2551..f0d64358b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod tabs; pub mod text_box; pub mod theme; pub mod widgets; +pub mod wordart; use std::collections::HashMap; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291accd..337bd0ccc 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1542,6 +1542,10 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "insert.textBox", json!({})); } + let wa = big_button(ui, Icon::WordArt, "Word\nArt", "Insert WordArt", 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() { diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index a0b30c025..a6571b7a1 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -32,7 +32,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. @@ -116,7 +116,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; }; @@ -150,7 +150,15 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr let response = ui.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) diff --git a/crates/ui-egui/src/wordart.rs b/crates/ui-egui/src/wordart.rs new file mode 100644 index 000000000..e524a8d86 --- /dev/null +++ b/crates/ui-egui/src/wordart.rs @@ -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(); + } + } + }); +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 076148a53..7002d13bb 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -199,7 +199,10 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { 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: a text body carrying a text warp (`a:bodyPr/a:prstTxWarp`). + let word_art = obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some(); let kind = match prst { + _ if word_art => ShapeKind::WordArt, _ if tx_box => ShapeKind::TextBox, "roundRect" => ShapeKind::RoundedRectangle, "ellipse" => ShapeKind::Ellipse, @@ -247,6 +250,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. @@ -364,7 +368,7 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { ShapeKind::Ellipse => ("ellipse", false), ShapeKind::Triangle => ("triangle", false), ShapeKind::Line | ShapeKind::Arrow => ("line", false), - ShapeKind::TextBox => ("rect", true), + ShapeKind::TextBox | ShapeKind::WordArt => ("rect", true), ShapeKind::Icon | ShapeKind::Ink => ("rect", false), }; let _ = write!( @@ -403,7 +407,15 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"", None => "", }; - let _ = write!(s, ""); + if sh.kind == ShapeKind::WordArt { + // Our WordArt marker: a (non-warping) text warp, which the reader maps back to WordArt. + let _ = write!( + s, + "" + ); + } else { + let _ = write!(s, ""); + } let ppr = match f.align { None => "", Some(ShapeTextAlign::Left) => "", @@ -456,6 +468,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, "{c}"); } + if let Some(c) = f.text_shadow.as_ref().and_then(|c| srgb(c, theme)) { + let _ = write!( + body, + "{c}" + ); + } if let Some(font) = &f.font { let _ = write!(body, "", esc(font)); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 960157254..3dc70bb81 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -347,6 +347,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), }, diff --git a/docs/gaps.md b/docs/gaps.md index 17a56b899..495ac6671 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -125,7 +125,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). ### 17. Insert objects (F) -- SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs +- SmartArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h. ### 18. Accessibility (U) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d218c8169..edcb8d19f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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: 274 / 290 commands implemented (94.5%).** +**Overall: 275 / 290 commands implemented (94.8%).** | Tab | Implemented | Total | Parity | |---|---|---|---| | File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | -| Insert | 17 | 25 | 68% | +| Insert | 18 | 25 | 72% | | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | @@ -139,7 +139,7 @@ Implemented: - `edit.goToSpecial` Go To Special (Editing) - `home.addIns` Add-ins (Add-ins) -## Insert (17 / 25, 68%) +## Insert (18 / 25, 72%) Implemented: @@ -158,6 +158,7 @@ Implemented: - `review.newComment` Comment (Comments) - `insert.textBox` Text Box (Text) - `pageLayout.headerFooter` Header & Footer (Text) +- `insert.wordArt` WordArt (Text) - `insert.symbol` Symbol (Symbols) - `insert.checkbox` Checkbox (Cell Controls) @@ -167,7 +168,6 @@ Missing: - `insert.screenshot` Screenshot (Illustrations) - `insert.map` Maps (Charts) - `insert.timeline` Timeline (Filters) -- `insert.wordArt` WordArt (Text) - `insert.signatureLine` Signature Line (Text) - `insert.object` Object (Text) - `insert.equation` Equation (Symbols) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b8209256..837f9c287 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **274 / 290 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **275 / 290 (94.8%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | @@ -89,7 +89,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 Break Preview, headers/footers, PDF export and printing. 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 (13 of 19 tools, no charts). Missing: Solver, Forecast Sheet, 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. Missing: SmartArt, WordArt, equations, 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. Missing: SmartArt, equations, 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). VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbccc..1a29de57f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,7 +4,7 @@ Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (274 / 290). +separately in [parity-checklist.md](parity-checklist.md) (275 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes From a39c6b8977b82a042410c5b2a659a88b89368399 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:09:27 +0200 Subject: [PATCH 02/16] Calc: SUBTOTAL(10x) recalculates when Row Height, AutoFit Row Height or Ungroup reveal rows home.rowHeight, home.autofitRowHeight and data.ungroup change row hidden state without marking the edit structural, so SUBTOTAL(101-111) kept a stale value. Found by the command-sequence fuzz (seed 3). Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/data.rs | 1 + crates/engine/src/cmd/format.rs | 2 ++ crates/engine/src/tests.rs | 16 ++++++++++++++++ 4 files changed, 20 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 6de6ea579..c6617b675 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| | 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 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index e4aa3baa6..d2b3612b7 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1129,6 +1129,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result { { e.collapsed = false; } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dde5fa120..8d4601029 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -953,6 +953,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result { e.custom = true; } } + cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } @@ -1069,6 +1070,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result { } cx.fit_rows.extend((a..=b).map(|r| (si, r))); } + cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it Ok(Json::Null) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7518263d8..8cff88957 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2874,3 +2874,19 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched"); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + // A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL). + s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); + s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(9.0)); + s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Number(15.0)); +} From 3109f2a9e0e6050398bcd87ffa8111e5c9a5366a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:47:39 +0200 Subject: [PATCH 03/16] Keytips: NW runs insert.wordArt now that the command exists; pending-sequence test uses NJ (insert.object) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 4 +--- crates/ui-egui/tests/keytips.rs | 2 +- crates/ui-egui/tests/keytips_generated.rs | 5 +---- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 143c15769..666a8c877 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -189,7 +189,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 diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index cbb8e0475..8e2e326bc 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1253,9 +1253,7 @@ "seq": "NW", "kind": "command", "target": "insert.wordArt", - "pending": true, - "excel": "WordArt", - "note": "WordArt; reserved until the command lands" + "excel": "WordArt" }, { "seq": "NX", diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 24be742a5..f1139c743 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -407,7 +407,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::J], false); // NJ -> insert.object (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"); diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 50c5731be..fe9700906 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -1377,10 +1377,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] From c7e4d6a19cf0c116ed2bc01f9a8d5aa85fe3a771 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:35:46 +0200 Subject: [PATCH 04/16] shape.setText: apply the WordArt length limit Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/insert.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 41b8fd37a..35daaea25 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1079,6 +1079,9 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { 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())); From caf897daadca2c804e2558b15931865107e1e0b2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 22 Oct 2026 19:40:36 +0200 Subject: [PATCH 05/16] Add WordArt labels to the catalogs added on main --- crates/ui-egui/src/i18n/el.rs | 2 ++ crates/ui-egui/src/i18n/it.rs | 2 ++ crates/ui-egui/src/i18n/ja.rs | 6 +++--- crates/ui-egui/src/i18n/nl.rs | 2 ++ crates/ui-egui/src/i18n/pl.rs | 2 ++ crates/ui-egui/src/i18n/ro.rs | 2 ++ crates/ui-egui/src/i18n/tr.rs | 2 ++ crates/ui-egui/src/i18n/uk.rs | 2 ++ crates/ui-egui/src/i18n/vi.rs | 2 ++ 9 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index e54416a6f..06f1222b5 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -95,6 +95,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Σχήματα", "Icons" => "Εικονίδια", "Text\nBox" => "Πλαίσιο\nκειμένου", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Εισαγωγή WordArt", "Comment" => "Σχόλιο", "New\nComment" => "Νέο\nσχόλιο", "New Note" => "Νέα σημείωση", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 65f356669..ab6aa3f2b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -99,6 +99,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Forme", "Icons" => "Icone", "Text\nBox" => "Casella\ndi testo", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Inserisci WordArt", "Comment" => "Commento", "New\nComment" => "Nuovo\ncommento", "New Note" => "Nuova nota", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 696902af2..402293585 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1085,15 +1085,15 @@ mod tests { assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}"); } assert_eq!(seen.len(), catalog_keys().count()); - assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers"); + assert_eq!(CORE.len(), 394, "the shared reference set every catalog covers"); assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it"); - assert_eq!(catalog_keys().count(), 954); + assert_eq!(catalog_keys().count(), 956); } #[test] fn covers_every_key_the_portuguese_catalog_translates() { let portuguese = portuguese_keys(); - assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count"); + assert_eq!(portuguese.len(), 956, "pt.rs's arms changed: add the new keys here and update the count"); let japanese: HashSet<&str> = catalog_keys().collect(); for key in &portuguese { assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not"); diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index c1f952357..141f774fd 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -100,6 +100,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Vormen", "Icons" => "Pictogrammen", "Text\nBox" => "Tekst-\nvak", + "Word\nArt" => "WordArt", + "Insert WordArt" => "WordArt invoegen", "Comment" => "Opmerking", "New\nComment" => "Nieuwe\nopmerking", "New Note" => "Nieuwe notitie", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e3185d55a..8d31a3bd8 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -95,6 +95,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Kształty", "Icons" => "Ikony", "Text\nBox" => "Pole\ntekstowe", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Wstaw WordArt", "Comment" => "Komentarz", "New\nComment" => "Nowy\nkomentarz", "New Note" => "Nowa notatka", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 699e30ff3..112ffbfcc 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -102,6 +102,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Forme", "Icons" => "Pictograme", "Text\nBox" => "Casetă\nde text", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Inserare WordArt", "Comment" => "Comentariu", "New\nComment" => "Comentariu\nnou", "New Note" => "Notă nouă", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index ce2bc8e1e..fbd254f86 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -98,6 +98,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Şekiller", "Icons" => "Simgeler", "Text\nBox" => "Metin\nKutusu", + "Word\nArt" => "WordArt", + "Insert WordArt" => "WordArt Ekle", "Comment" => "Açıklama", "New\nComment" => "Yeni\nAçıklama", "New Note" => "Yeni Not", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 97659cc12..e1cfdff3c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -97,6 +97,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Фігури", "Icons" => "Значки", "Text\nBox" => "Текстове\nполе", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Вставити WordArt", "Comment" => "Коментар", "New\nComment" => "Новий\nкоментар", "New Note" => "Нова нотатка", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 94c32b3a5..0b71e1fa5 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -103,6 +103,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Hình", "Icons" => "Biểu tượng", "Text\nBox" => "Hộp\nvăn bản", + "Word\nArt" => "WordArt", + "Insert WordArt" => "Chèn WordArt", "Comment" => "Chú thích", "New\nComment" => "Chú thích\nmới", "New Note" => "Ghi chú mới", From 4b75f3ea0085739017aada029e71ea7647e83508 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 22 Oct 2026 20:26:34 +0200 Subject: [PATCH 06/16] docs: regenerate parity checklist after merging main Co-Authored-By: Claude Sonnet 5.5 --- docs/parity-checklist.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee8bcfb75..91aa6bfc3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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: 275 / 291 commands implemented (94.5%).** +**Overall: 276 / 291 commands implemented (94.8%).** | Tab | Implemented | Total | Parity | |---|---|---|---| | File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | -| Insert | 17 | 25 | 68% | +| Insert | 18 | 25 | 72% | | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | @@ -139,7 +139,7 @@ Implemented: - `edit.goToSpecial` Go To Special (Editing) - `home.addIns` Add-ins (Add-ins) -## Insert (17 / 25, 68%) +## Insert (18 / 25, 72%) Implemented: @@ -158,6 +158,7 @@ Implemented: - `review.newComment` Comment (Comments) - `insert.textBox` Text Box (Text) - `pageLayout.headerFooter` Header & Footer (Text) +- `insert.wordArt` WordArt (Text) - `insert.symbol` Symbol (Symbols) - `insert.checkbox` Checkbox (Cell Controls) @@ -167,7 +168,6 @@ Missing: - `insert.screenshot` Screenshot (Illustrations) - `insert.map` Maps (Charts) - `insert.timeline` Timeline (Filters) -- `insert.wordArt` WordArt (Text) - `insert.signatureLine` Signature Line (Text) - `insert.object` Object (Text) - `insert.equation` Equation (Symbols) From f9bd1e7eca87be44edf9840f8c4ab460fe2ec168 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 20:52:27 +0100 Subject: [PATCH 07/16] Fix WordArt for ActiveSheet API change after merge Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/wordart.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs index f300e16a9..ded5bda6d 100644 --- a/crates/engine/src/cmd/wordart.rs +++ b/crates/engine/src/cmd/wordart.rs @@ -197,7 +197,7 @@ fn insert_word_art(s: &mut Session, p: &Json) -> Result { 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; + let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.shapes.push(shape); Ok(json!({"shape": id})) @@ -209,7 +209,7 @@ fn set_word_art(s: &mut Session, p: &Json) -> Result { 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 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 { From f8a764f20c773156c531e5ad2d3a3a442a193adb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 2 Nov 2026 20:07:54 +0100 Subject: [PATCH 08/16] Fix merge: Shape::math for WordArt, fmt Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/wordart.rs | 1 + crates/ui-egui/src/objects.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs index a56ffe4e9..13ce827aa 100644 --- a/crates/engine/src/cmd/wordart.rs +++ b/crates/engine/src/cmd/wordart.rs @@ -190,6 +190,7 @@ fn insert_word_art(s: &mut Session, p: &Json) -> Result { 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. diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 730f339fb..eea7480c0 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -126,7 +126,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 && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) => 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::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select, } } From 5b3b3bdc0e9a4a630cb1ac9c7a3f8a46ccbac9b6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 2 Nov 2026 20:19:10 +0100 Subject: [PATCH 09/16] Keytip pending-sequence test: use NSC so it does not race the NJ go-live test Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/keytips.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 99198bd13..cf765e031 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -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::J], false); // NJ -> insert.object (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"); From f3e24e2b68990125bbff6da1c1afa84945d32c4c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 2 Nov 2026 20:58:00 +0100 Subject: [PATCH 10/16] Regenerate parity checklist after merging main The parity CI step failed because docs/parity-checklist.md was stale after merging upstream main. Regenerated with cargo xtask parity. Counts now 278 / 292 (Insert: WordArt moved to implemented). Co-Authored-By: Claude Haiku 5.5 --- docs/parity-checklist.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5ca7ada2f..10259f303 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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: 277 / 292 commands implemented (94.9%).** +**Overall: 278 / 292 commands implemented (95.2%).** | 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% | @@ -139,7 +139,7 @@ Implemented: - `edit.goToSpecial` Go To Special (Editing) - `home.addIns` Add-ins (Add-ins) -## Insert (18 / 25, 72%) +## Insert (19 / 25, 76%) Implemented: @@ -158,6 +158,7 @@ Implemented: - `review.newComment` Comment (Comments) - `insert.textBox` Text Box (Text) - `pageLayout.headerFooter` Header & Footer (Text) +- `insert.wordArt` WordArt (Text) - `insert.equation` Equation (Symbols) - `insert.symbol` Symbol (Symbols) - `insert.checkbox` Checkbox (Cell Controls) @@ -168,7 +169,6 @@ Missing: - `insert.screenshot` Screenshot (Illustrations) - `insert.map` Maps (Charts) - `insert.timeline` Timeline (Filters) -- `insert.wordArt` WordArt (Text) - `insert.signatureLine` Signature Line (Text) - `insert.object` Object (Text) From 2a9a21d41c115d2db24141ea7de02aafba01134e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 04:25:50 +0100 Subject: [PATCH 11/16] WordArt xlsx: only the textNoShape marker reads as WordArt, an empty WordArt keeps its body Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/drawing.rs | 8 +++++--- crates/xlsx/src/tests/shape_text_rtl.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 8cd1d353c..ebda1e8f3 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -221,8 +221,9 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { 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: a text body carrying a text warp (`a:bodyPr/a:prstTxWarp`). - let word_art = obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some(); + // WordArt: our marker, a text body with the non-warping text warp `textNoShape`. Any other warp + // (Excel puts one on ordinary filled shapes too) leaves the shape what its geometry says. + let word_art = 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, @@ -554,7 +555,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { s.push_str(""); } s.push_str(""); - 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\"", diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs index 0b9e5b10b..0ec5944bb 100644 --- a/crates/xlsx/src/tests/shape_text_rtl.rs +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -100,3 +100,24 @@ fn word_art_keeps_right_to_left_paragraphs() { 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#""#, "") + .replace(r#"prst="rect""#, r#"prst="ellipse""#) + .replace(r#"anchor="t"/>"#, r#"anchor="t">"#); + let wb = read_drawing(&drawing); + assert_eq!(wb.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse); + // The marker the writer uses still reads back as WordArt. + let marked = drawing.replace("textTriangle", "textNoShape"); + assert_eq!(read_drawing(&marked).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); +} From dd5cd48b8f75a7ef4755c853fe1d271ec81ee814 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 04:29:48 +0100 Subject: [PATCH 12/16] Regenerate parity checklist after merging main Co-Authored-By: Claude Sonnet 5.5 --- docs/parity-checklist.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4100ec630..1855ea066 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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` WordArt (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` WordArt (Text) - `insert.signatureLine` Signature Line (Text) - `insert.object` Object (Text) From 224d0e96d7fe18bd74b4de16bf8417b480c38687 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 17:32:14 +0100 Subject: [PATCH 13/16] WordArt: refuse insertion on a protected sheet Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/wordart.rs | 3 +++ crates/engine/src/tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs index 13ce827aa..3577591a6 100644 --- a/crates/engine/src/cmd/wordart.rs +++ b/crates/engine/src/cmd/wordart.rs @@ -180,6 +180,9 @@ fn insert_word_art(s: &mut Session, p: &Json) -> Result { 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e792f09a0..7fbec87f7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -494,6 +494,20 @@ 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; From 14aae7cee1c7a513cca65996713bb65b78229f92 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 12:52:29 +0100 Subject: [PATCH 14/16] WordArt: keep ordinary shapes with textNoShape; print the text outline textNoShape is the standard no-warp value, so it alone no longer makes a shape WordArt: only a rectangle that is a text box (as the writer saves WordArt) or has no fill and no outline reads as WordArt. A filled ellipse with an explicit textNoShape now reads and saves as the same ellipse. The PDF/print path drew WordArt text without its outline, so Mint Outline (white text, green outline) printed as a blank page. The outline is now drawn like on screen: the text in the outline colour, offset in eight directions, under the filled text. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print/objects.rs | 10 +++++++ crates/engine/src/tests.rs | 19 +++++++++++++ crates/xlsx/src/drawing.rs | 11 +++++--- crates/xlsx/src/tests/shape_text_rtl.rs | 36 ++++++++++++++++++++++--- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs index c6e296600..0f62b8b93 100644 --- a/crates/engine/src/cmd/print/objects.rs +++ b/crates/engine/src/cmd/print/objects.rs @@ -208,6 +208,16 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl 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; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a2c8e028a..18ffdefe9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -587,6 +587,25 @@ fn word_art_survives_xlsx_roundtrip() { 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; diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index ebda1e8f3..cf033db08 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -221,9 +221,14 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { 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`. Any other warp - // (Excel puts one on ordinary filled shapes too) leaves the shape what its geometry says. - let word_art = obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some_and(|w| w.attr("prst") == Some("textNoShape")); + // 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, diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs index 0ec5944bb..c750a199f 100644 --- a/crates/xlsx/src/tests/shape_text_rtl.rs +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -117,7 +117,37 @@ fn a_text_warp_on_a_filled_ellipse_does_not_make_it_word_art() { .replace(r#"anchor="t"/>"#, r#"anchor="t">"#); let wb = read_drawing(&drawing); assert_eq!(wb.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse); - // The marker the writer uses still reads back as WordArt. - let marked = drawing.replace("textTriangle", "textNoShape"); - assert_eq!(read_drawing(&marked).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); +} + +/// `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#""#, "") + .replace( + r#""#, + r#""#, + ) + .replace(r#"anchor="t"/>"#, r#"anchor="t">"#); + 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#""#), "{xml}"); + assert!(xml.contains(r#""#), "{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">"#); + assert_eq!(read_drawing(&text_box).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); + let bare = text_box + .replace(r#""#, "") + .replace(r#""#, r#""#); + assert_eq!(read_drawing(&bare).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); } From 40c1afbe8432e67877f9d9067f4ef6d51aef1d07 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 10 Nov 2026 22:17:09 +0100 Subject: [PATCH 15/16] Use the Decorative Text label for the WordArt ribbon button Upstream retires WordArt as a display name (display_names guard); the button and its tooltip use the already translated Decorative Text key, and the parity checklist is regenerated. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/i18n/cs.rs | 2 -- crates/ui-egui/src/i18n/de.rs | 2 -- crates/ui-egui/src/i18n/el.rs | 2 -- crates/ui-egui/src/i18n/es.rs | 2 -- crates/ui-egui/src/i18n/fr.rs | 2 -- crates/ui-egui/src/i18n/it.rs | 2 -- crates/ui-egui/src/i18n/ja.rs | 2 -- crates/ui-egui/src/i18n/ko.rs | 2 -- crates/ui-egui/src/i18n/nl.rs | 2 -- crates/ui-egui/src/i18n/pl.rs | 2 -- crates/ui-egui/src/i18n/pt.rs | 2 -- crates/ui-egui/src/i18n/ro.rs | 2 -- crates/ui-egui/src/i18n/ru.rs | 2 -- crates/ui-egui/src/i18n/sr.rs | 2 -- crates/ui-egui/src/i18n/tr.rs | 2 -- crates/ui-egui/src/i18n/uk.rs | 2 -- crates/ui-egui/src/i18n/vi.rs | 2 -- crates/ui-egui/src/i18n/zh.rs | 2 -- crates/ui-egui/src/ribbon/insert.rs | 2 +- docs/parity-checklist.md | 8 ++++---- 20 files changed, 5 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index f8ed6f601..eaee085cf 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -100,8 +100,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Obrazce", "Icons" => "Ikony", "Text\nBox" => "Textové\npole", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Vložit WordArt", "Comment" => "Komentář", "New\nComment" => "Nový\nkomentář", "New Note" => "Nová poznámka", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 191b251b9..07fdb6710 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -104,8 +104,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formen", "Icons" => "Symbole", "Text\nBox" => "Text-\nfeld", - "Word\nArt" => "WordArt", - "Insert WordArt" => "WordArt einfügen", "Comment" => "Kommentar", "New\nComment" => "Neuer\nKommentar", "New Note" => "Neue Notiz", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 9dad30b7e..ec42b3473 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -99,8 +99,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Σχήματα", "Icons" => "Εικονίδια", "Text\nBox" => "Πλαίσιο\nκειμένου", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Εισαγωγή WordArt", "Comment" => "Σχόλιο", "New\nComment" => "Νέο\nσχόλιο", "New Note" => "Νέα σημείωση", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 9cf0593bd..db78d8105 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -103,8 +103,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formas", "Icons" => "Iconos", "Text\nBox" => "Cuadro\nde texto", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Insertar WordArt", "Comment" => "Comentario", "New\nComment" => "Nuevo\ncomentario", "New Note" => "Nueva nota", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index aeba9dc34..fd5e23b5b 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -110,8 +110,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formes", "Icons" => "Icônes", "Text\nBox" => "Zone de\ntexte", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Insérer un WordArt", "Comment" => "Commentaire", "New\nComment" => "Nouveau\ncommentaire", "New Note" => "Nouvelle note", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e4f7fa8b7..dcede52cc 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -103,8 +103,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Forme", "Icons" => "Icone", "Text\nBox" => "Casella\ndi testo", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Inserisci WordArt", "Comment" => "Commento", "New\nComment" => "Nuovo\ncommento", "New Note" => "Nuova nota", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index c96ba1d83..d558be6ec 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -107,8 +107,6 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Shapes", "図形"), ("Icons", "アイコン"), ("Text\nBox", "テキスト ボックス"), - ("Word\nArt", "ワードアート"), - ("Insert WordArt", "ワードアートの挿入"), ("Comment", "コメント"), ("New\nComment", "新しいコメント"), ("New Note", "新しいメモ"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 796b992ca..ca2710272 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -105,8 +105,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "도형", "Icons" => "아이콘", "Text\nBox" => "텍스트\n상자", - "Word\nArt" => "워드\n아트", - "Insert WordArt" => "워드아트 삽입", "Comment" => "댓글", "New\nComment" => "새 댓글", "New Note" => "새 메모", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 110e2f62d..64c24b1f2 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -104,8 +104,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Vormen", "Icons" => "Pictogrammen", "Text\nBox" => "Tekst-\nvak", - "Word\nArt" => "WordArt", - "Insert WordArt" => "WordArt invoegen", "Comment" => "Opmerking", "New\nComment" => "Nieuwe\nopmerking", "New Note" => "Nieuwe notitie", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 961149546..29264ce91 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -99,8 +99,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Kształty", "Icons" => "Ikony", "Text\nBox" => "Pole\ntekstowe", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Wstaw WordArt", "Comment" => "Komentarz", "New\nComment" => "Nowy\nkomentarz", "New Note" => "Nowa notatka", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 90819d028..3cc4faf76 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -102,8 +102,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Formas", "Icons" => "Ícones", "Text\nBox" => "Caixa\nde Texto", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Inserir WordArt", "Comment" => "Comentário", "New\nComment" => "Novo\nComentário", "New Note" => "Nova Nota", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 238d5060a..3d5a193d1 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -106,8 +106,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Forme", "Icons" => "Pictograme", "Text\nBox" => "Casetă\nde text", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Inserare WordArt", "Comment" => "Comentariu", "New\nComment" => "Comentariu\nnou", "New Note" => "Notă nouă", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 53a1f7414..2d4d3fe1f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -105,8 +105,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Фигуры", "Icons" => "Значки", "Text\nBox" => "Текстовое\nполе", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Вставить WordArt", "Comment" => "Комментарий", "New\nComment" => "Новый\nкомментарий", "New Note" => "Новая заметка", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 42bd25b11..a0eac0a4f 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -106,8 +106,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Oblici", "Icons" => "Ikone", "Text\nBox" => "Tekstualni\nokvir", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Umetni WordArt", "Comment" => "Komentar", "New\nComment" => "Novi\nkomentar", "New Note" => "Nova beleška", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index f00d6431d..ed3d0bf01 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -102,8 +102,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Şekiller", "Icons" => "Simgeler", "Text\nBox" => "Metin\nKutusu", - "Word\nArt" => "WordArt", - "Insert WordArt" => "WordArt Ekle", "Comment" => "Açıklama", "New\nComment" => "Yeni\nAçıklama", "New Note" => "Yeni Not", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 61d0156de..7059a0ff4 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -101,8 +101,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Фігури", "Icons" => "Значки", "Text\nBox" => "Текстове\nполе", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Вставити WordArt", "Comment" => "Коментар", "New\nComment" => "Новий\nкоментар", "New Note" => "Нова нотатка", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index f15577710..260f9572d 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -107,8 +107,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "Hình", "Icons" => "Biểu tượng", "Text\nBox" => "Hộp\nvăn bản", - "Word\nArt" => "WordArt", - "Insert WordArt" => "Chèn WordArt", "Comment" => "Chú thích", "New\nComment" => "Chú thích\nmới", "New Note" => "Ghi chú mới", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 0a2c3190d..ff671a7c9 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -105,8 +105,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Shapes" => "形状", "Icons" => "图标", "Text\nBox" => "文本\n框", - "Word\nArt" => "艺术字", - "Insert WordArt" => "插入艺术字", "Comment" => "批注", "New\nComment" => "新建\n批注", "New Note" => "新建备注", diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 3e7168904..50be093f0 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -208,7 +208,7 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "insert.textBox", json!({})); } - let wa = big_button(ui, Icon::WordArt, "Word\nArt", "Insert WordArt", true); + let wa = big_button(ui, Icon::WordArt, "Decorative Text", "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)); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ad7e680f9..542328139 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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) From 60063b85cd0a26c973c4f6fad5e1c1fa03405a26 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 10 Nov 2026 23:20:06 +0100 Subject: [PATCH 16/16] Keep the Decorative Text ribbon button two lines wide The one-line label widened the Insert tab enough to scroll the Equation button out of view at 1400 px, which broke the equation editor tests in CI. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ribbon/insert.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 50be093f0..b29257c76 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -208,7 +208,9 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "insert.textBox", json!({})); } - let wa = big_button(ui, Icon::WordArt, "Decorative Text", "Decorative Text", true); + // 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));