From 470968fcdcdac7378966e4209faa4fbeff0e55e1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:38:11 +0200 Subject: [PATCH] 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