diff --git a/ROADMAP.md b/ROADMAP.md index cb4065a2c..85da0901f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) | | 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) | | 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained | +| 2026-10-12 | Insert › Text › WordArt (`insert.wordArt`, `wordArt.set`): floating decorative text with 10 original styles, outline and shadow, in-place editing, XLSX and print (#784) | | 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) | | 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts | | 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) | @@ -313,6 +314,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) | | 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) | | 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries | +| 2026-10-12 | minor | Catalog 276 → 277 / 292: Insert › WordArt (#784) | | 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row | | 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 0f79495fc..37e9cd082 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -26,6 +26,7 @@ pub mod spelling; pub mod stash; pub mod view; pub mod whatif; +pub mod wordart; use gridcraft_calc::Key; use gridcraft_core::{CellRef, RangeRef}; @@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(extra::specs()); v.extend(macros::specs()); v.extend(draw::specs()); + v.extend(wordart::specs()); v.extend(spelling::specs()); v.extend(solver::specs()); v.extend(pivot::specs()); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index d832ecddf..4bd564db3 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1222,9 +1222,15 @@ 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")); + } + 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())); diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs index d978ff580..55d9f8ac8 100644 --- a/crates/engine/src/cmd/print/objects.rs +++ b/crates/engine/src/cmd/print/objects.rs @@ -213,6 +213,21 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl ShapeTextAlign::Center => x + (w - lw) / 2.0, ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, }; + // WordArt's drop shadow: the line again, offset, in the shadow colour (under the text). + if let Some(sc) = fm.text_shadow.and_then(|c| rgb_of(c, wb)) { + let off = (size * 0.06).max(0.5); + pg.text(lx + off, yy + off, size, font, sc, l); + } + // WordArt's text outline, as on screen: the line in the outline colour, offset in eight + // directions by half the outline width, under the filled text. + if let Some((c, ow)) = fm.text_outline + && let Some(oc) = rgb_of(c, wb) + { + let d = (ow * b.scale / 2.0).clamp(0.25, 20.0); + for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] { + pg.text(lx + dx * d, yy + dy * d, size, font, oc, l); + } + } pg.text(lx, yy, size, font, color, l); yy += size * 1.2; } diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs new file mode 100644 index 000000000..3577591a6 --- /dev/null +++ b/crates/engine/src/cmd/wordart.rs @@ -0,0 +1,241 @@ +//! Insert › Text › WordArt: decorative text as a floating shape (`ShapeKind::WordArt`) built on +//! the text-box machinery (anchor, move/resize/delete, undo, XLSX shape text body, printing). +//! The style palette below is original to GridCraft. + +use gridcraft_model::{Anchor, Color, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor}; +use serde_json::{Value as Json, json}; + +use super::{ + CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, + u32_param, +}; + +/// One entry of the style gallery. +pub struct WordArtStyle { + pub name: &'static str, + pub fill: Color, + pub outline: Option<(Color, f32)>, + pub shadow: Option, + 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()?; + if d.wb.active().is_some_and(|sh| sh.is_protected()) { + return Err(EngineError::Other(PROTECTED.into())); + } + let id = d.wb.next_object_id(); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); + let mut shape = Shape { + id, + kind: ShapeKind::WordArt, + anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width: 0.0, height: 0.0, mode: anchor_mode_param(p).unwrap_or_default() }, + fill: Color::Auto, + line: Color::Auto, + text: text.clone(), + format: ShapeFormat { no_line: true, align: Some(ShapeTextAlign::Center), anchor: Some(ShapeTextAnchor::Middle), ..Default::default() }, + math: Default::default(), + }; + apply("insert.wordArt", &mut shape, Some(style), p)?; + // Default box: the longest line at the font size (about 0.62 em per character) plus padding. + let pt = f64::from(shape.format.font_size.unwrap_or(36.0)); + let longest = text.split('\n').map(|l| l.chars().count()).max().unwrap_or(1) as f64; + let lines = text.split('\n').count() as f64; + let width = f64_param(p, "width").unwrap_or(longest * pt * 0.62 * 96.0 / 72.0 + 24.0).clamp(8.0, MAX_SIDE); + let height = f64_param(p, "height").unwrap_or(lines * pt * 1.35 * 96.0 / 72.0 + 12.0).clamp(8.0, MAX_SIDE); + shape.anchor.width = width as f32; + shape.anchor.height = height as f32; + let sheet = d.wb.active_sheet(); + edit(s, |cx| { + cx.sheet_mut(sheet)?.shapes.push(shape); + Ok(json!({"shape": id})) + }) +} + +fn set_word_art(s: &mut Session, p: &Json) -> Result { + 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 927cfb544..f5c17ba16 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -532,6 +532,118 @@ fn shape_text_edit_survives_xlsx_roundtrip() { assert_eq!(shape.text, text); } +#[test] +fn word_art_insert_respects_sheet_protection() { + let mut s = s(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + let undo_len = s.doc().unwrap().undo.len(); + assert!(s.execute("insert.wordArt", json!({})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(s.doc().unwrap().undo.len(), undo_len); + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("insert.wordArt", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1); +} + +#[test] +fn word_art_inserts_with_defaults_and_undoes() { + use gridcraft_model::ShapeKind; + let mut s = s(); + // Empty params: default style and text, no dialog. + let r = s.execute("insert.wordArt", json!({})).unwrap(); + assert!(s.take_ui_requests().is_empty()); + let shape = s.doc().unwrap().wb.active().unwrap().shapes[0].clone(); + assert_eq!(shape.id, r["shape"].as_u64().unwrap() as u32); + assert_eq!(shape.kind, ShapeKind::WordArt); + assert_eq!(shape.text, crate::cmd::wordart::DEFAULT_TEXT); + assert!(shape.anchor.width > 8.0 && shape.format.text_color.is_some()); + // Named style plus overrides; restyle and edit the text through wordArt.set. + s.execute("insert.wordArt", json!({"style": "ember", "text": "Q3", "fontSize": 60, "shadow": false, "outline": "#112233", "outlineWidth": 3})) + .unwrap(); + let ember = s.doc().unwrap().wb.active().unwrap().shapes[1].clone(); + assert_eq!(ember.format.font_size, Some(60.0)); + assert_eq!((ember.format.text_shadow, ember.format.text_outline.map(|o| o.1)), (None, Some(3.0))); + s.execute("wordArt.set", json!({"id": ember.id, "style": 3, "text": "Q4"})).unwrap(); + let styled = s.doc().unwrap().wb.active().unwrap().shapes[1].clone(); + assert_eq!((styled.text.as_str(), styled.format.italic), ("Q4", true)); + s.execute("shape.setText", json!({"id": ember.id, "text": "Q5"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1], ember); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1].text, "Q4"); + // Moves and deletes like any floating shape. + s.execute("object.move", json!({"kind": "shape", "id": ember.id, "at": "D4"})).unwrap(); + s.execute("object.delete", json!({"kind": "shape", "id": ember.id})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1); +} + +#[test] +fn word_art_rejects_hostile_params() { + let mut s = s(); + let id = s.execute("insert.wordArt", json!({})).unwrap()["shape"].clone(); + let rectangle = s.execute("insert.shape", json!({"kind": "rectangle"})).unwrap()["shape"].clone(); + let before = s.doc().unwrap().wb.clone(); + for (cmd, params) in [ + ("insert.wordArt", json!({"style": "No Such Style"})), + ("insert.wordArt", json!({"style": 99})), + ("insert.wordArt", json!({"style": -1})), + ("insert.wordArt", json!({"text": 5})), + ("insert.wordArt", json!({"text": ""})), + ("insert.wordArt", json!({"text": "x".repeat(crate::cmd::wordart::MAX_CHARS + 1)})), + ("insert.wordArt", json!({"fill": "not a colour"})), + ("wordArt.set", json!({})), + ("wordArt.set", json!({"id": 999})), + ("wordArt.set", json!({"id": rectangle})), + ("shape.setText", json!({"id": id, "text": ""})), + ] { + assert!(matches!(s.execute(cmd, params), Err(crate::EngineError::BadParams { .. })), "{cmd}"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + } + // Absurd sizes are clamped, not trusted. + s.execute("insert.wordArt", json!({"fontSize": 1e12, "width": 1e300, "height": -5})).unwrap(); + let big = &s.doc().unwrap().wb.active().unwrap().shapes[2]; + assert!(big.anchor.width <= 4000.0 && big.anchor.height >= 8.0 && big.format.font_size == Some(400.0)); +} + +#[test] +fn word_art_survives_xlsx_roundtrip() { + use gridcraft_model::ShapeKind; + let mut s = s(); + s.execute("insert.wordArt", json!({"style": "Lagoon", "text": "Sales <2026>\nOutlook & more"})).unwrap(); + let before = s.doc().unwrap().wb.active().unwrap().shapes[0].clone(); + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "wordart.xlsx", "base64": saved["base64"]})).unwrap(); + let after = &s.doc().unwrap().wb.active().unwrap().shapes[0]; + assert_eq!(after.kind, ShapeKind::WordArt); + assert_eq!(after.text, before.text); + assert_eq!(after.format.text_color, before.format.text_color); + assert_eq!(after.format.text_outline, before.format.text_outline); + assert_eq!(after.format.text_shadow, before.format.text_shadow); + assert_eq!((after.format.bold, after.format.italic, after.format.font_size), (true, true, before.format.font_size)); + assert!(after.format.no_line); +} + +/// White text with a green outline (Mint Outline) prints its outline, so it is not invisible on a +/// white page. +#[test] +fn word_art_outline_prints() { + let mut s = s(); + s.execute("insert.wordArt", json!({"style": "Mint Outline", "text": "VISIBLE", "at": "A1"})).unwrap(); + let pdf = s.execute("file.exportPdf", json!({"range": "A1:G10"})).unwrap(); + let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap(); + let text = String::from_utf8_lossy(&data); + let outline = text.matches("0.18 0.608 0.416 rg").count(); + assert!(outline >= 1, "the outline colour (2E9B6A) is drawn"); + assert_eq!(text.matches("(VISIBLE) Tj").count(), 9, "eight outline copies under the white text"); + assert!(text.contains("1 1 1 rg"), "the white fill is drawn on top"); + // The last copy drawn is the filled text, in white; every outline copy comes before it. + let before_fill = &text[..text.rfind("(VISIBLE) Tj").unwrap()]; + assert!(before_fill.rfind("1 1 1 rg") > before_fill.rfind("0.18 0.608 0.416 rg"), "the white fill is drawn last"); + assert_eq!(before_fill.matches("(VISIBLE) Tj").count(), 8); +} + #[test] fn no_border_clears_only_shared_edges_and_undo_restores_them() { use gridcraft_core::RangeRef; diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 8ab74606a..5c9f961d7 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -668,6 +668,9 @@ pub enum ShapeKind { /// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the /// anchor (sheet points), `Shape::line` the ink colour and `fill` unused. Ink, + /// Decorative text (`insert.wordArt`): a transparent shape whose `Shape::text` is drawn with + /// the large, filled/outlined/shadowed text properties of `Shape::format`. + WordArt, /// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour /// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout. Equation, @@ -1026,6 +1029,8 @@ pub struct ShapeFormat { pub text_color: Option, /// 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 dbfba039c..92d693361 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -198,10 +198,11 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh { fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Shape, r: Rect, z: f32) { use gridcraft_engine::model::{ShapeTextAlign, ShapeTextAnchor}; let f = &sp.format; + let word_art = sp.kind == ShapeKind::WordArt; let text_box = sp.kind == ShapeKind::TextBox; let align = f.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); let anchor = f.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); - let default_color = if text_box { Color32::BLACK } else { Color32::WHITE }; + let default_color = if text_box || word_art { Color32::BLACK } else { Color32::WHITE }; let color = f.text_color.and_then(|c| c.resolve(&wb.theme)).map(theme::color32).unwrap_or(default_color); // Default 14 px (as before); a set size is in points. let size = f.font_size.map_or(14.0, |pt| pt * 96.0 / 72.0).clamp(1.0, 2000.0) * z; @@ -227,6 +228,11 @@ fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Sh }; let pos = pos2(x, y); let clip = p.with_clip_rect(r.intersect(p.clip_rect())); + // WordArt's drop shadow: the text again, offset down and right, under everything else. + if let Some(sc) = f.text_shadow.and_then(|c| c.resolve(&wb.theme)).map(theme::color32) { + let d = (size * 0.05).clamp(1.0, 24.0); + clip.galley_with_override_text_color(pos + vec2(d, d), g.clone(), sc); + } // egui has no stroked text: the outline is the text drawn in the outline colour, offset in // eight directions by the outline width, under the filled text. if let Some((c, w)) = f.text_outline @@ -295,6 +301,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo let line = if sp.format.no_line { Color32::TRANSPARENT } else { sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY) }; match sp.kind { + // WordArt is only its text (no fill or outline of its own). + ShapeKind::WordArt => {} ShapeKind::Rectangle | ShapeKind::TextBox => { p.rect_filled(r, 0.0, fill); p.rect_stroke(r, 0.0, Stroke::new(1.0, line), StrokeKind::Inside); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a8768ff83..58cb0251c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -103,6 +103,7 @@ pub enum Icon { Comment, Note, TextBox, + WordArt, Symbol, /// An equation: a radical sign over an italic x. Equation, @@ -631,6 +632,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c)); g.text(8.0, 8.0, "A", 8.0, c, (false, false)); } + Icon::WordArt => { + g.text(9.0, 9.0, "A", 13.0, Color32::from_rgb(0x8E, 0x4F, 0xB5), (true, false)); + g.text(8.0, 8.0, "A", 13.0, c, (true, false)); + } Icon::Symbol => g.text(8.0, 8.0, "Ω", 12.0, c, (false, false)), Icon::Equation => { g.line(&[(1.5, 9.0), (3.5, 8.0), (5.5, 13.5), (8.5, 2.5), (14.5, 2.5)], c, 1.2); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c8244682f..7651bf344 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -194,7 +194,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("NU", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Symbols; containment(Symbol) ("NZS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Excel: Symbol (modern) ("NV", Entry::Finish(Action::Command("insert.pivotTable"))), // Excel: PivotTable - ("NW", Entry::Finish(Action::Pending("insert.wordArt"))), // pending; Excel: WordArt; WordArt; reserved until the command lands + ("NW", Entry::Finish(Action::Command("insert.wordArt"))), // Excel: WordArt ("NX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box ("NZK", Entry::Continue), // Excel: Sparklines Menu; group level: ("NZKSL", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Line Sparkline diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 0f5a4b8e0..daaa416f9 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1336,9 +1336,7 @@ "seq": "NW", "kind": "command", "target": "insert.wordArt", - "pending": true, - "excel": "WordArt", - "note": "WordArt; reserved until the command lands" + "excel": "WordArt" }, { "seq": "NX", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2bcf3cf0..cea12b880 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -59,6 +59,7 @@ pub mod theme; mod updates_ui; pub mod widgets; mod window_ui; +pub mod wordart; pub use window_ui::WindowUi; diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 9a45cca4f..3972c5b68 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -128,7 +128,9 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction { match o.kind { ObjKind::Chart => OpenAction::FormatChart(o.id), ObjKind::Equation => OpenAction::EditEquation(o.id), - ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id), + ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) => { + OpenAction::EditText(o.id) + } ObjKind::Control => OpenAction::EditControl(o.id), ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select, } diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 1fd433fad..b29257c76 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -208,6 +208,12 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "insert.textBox", json!({})); } + // Two lines like its neighbours, so the Insert tab stays as wide as before; the translated label is split, the catalog key is one line. + let label = app.ui.language.tr("Decorative Text").replacen(' ', "\n", 1); + let wa = big_button(ui, Icon::WordArt, &label, "Decorative Text", true); + anchor_keytip(app, ui, &wa, &["insert.wordArt"]); + // Menu invocation shows the style gallery; the command itself never opens a dialog. + egui::Popup::menu(&wa).show(|ui| crate::wordart::gallery(app, ui)); let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false); control_keytip(app, ui, &__k, "dialog:headerFooter"); if __k.clicked() { diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index b14a6e95a..7324abcfb 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -33,7 +33,7 @@ impl SheetApp { } let Some(d) = self.session.active() else { return false }; let Some(sh) = d.wb.active() else { return false }; - let Some(shape) = sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox) else { + let Some(shape) = sh.shapes.iter().find(|s| s.id == id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else { return false; }; // shape.setText refuses edits on a protected sheet; don't open an editor that can't commit. @@ -118,7 +118,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr app.text_box_editor = Some(ed); return; } - let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && s.kind == ShapeKind::TextBox) else { + let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else { app.session.mode = Mode::Ready; return; }; @@ -153,7 +153,15 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr .add( TextEdit::multiline(&mut ed.text) .id(id) - .font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into()))) + .font(FontId::new( + // WordArt is edited at its own size (clamped so the box stays usable). + if shape.kind == ShapeKind::WordArt { + shape.format.font_size.map_or(14.0, |pt| (pt * 96.0 / 72.0).clamp(8.0, 200.0)) + } else { + 14.0 + } * geo.z, + egui::FontFamily::Name(theme::CELL.into()), + )) .text_color(Color32::BLACK) .frame(egui::Frame::NONE) .margin(egui::Margin::ZERO) 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/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 63aa6d8b4..86001238f 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -1417,10 +1417,7 @@ fn keytip_nv_() { fn keytip_nw_() { let mut h = harness(blank()); sequence(&mut h, "NW", false); - assert!(h.state().dialog.is_none(), "NW must not open a dialog"); - assert!(h.state().message.is_none(), "NW must not open a message box"); - assert!(h.state().toast.is_none(), "NW must not raise a toast"); - assert!(h.state().session.journal.is_empty(), "NW must not journal"); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.wordArt"), "NW must journal insert.wordArt"); } #[test] diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index c29f58453..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::W], false); // NW -> insert.wordArt (missing) + sequence(&mut h, &[Key::N, Key::S, Key::C], false); // NSC -> insert.screenshot (missing) assert!(h.state().dialog.is_none(), "no dialog for a pending sequence"); assert!(h.state().message.is_none(), "no message box for a pending sequence"); assert!(h.state().toast.is_none(), "no toast for a pending sequence"); diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index cebc11460..6ef49f175 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -221,7 +221,16 @@ 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`, on a rectangle + // that is a text box (as the writer saves it) or has no fill and no outline of its own (as + // WordArt usually comes). `textNoShape` is the standard "no warp" value, so on an ordinary + // shape (an ellipse, a filled rectangle) it, like any other warp, leaves the shape what its + // geometry says. + let word_art = prst == "rect" + && (tx_box || (sp.child("noFill").is_some() && !ln.is_some_and(|l| l.child("solidFill").is_some()))) + && obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some_and(|w| w.attr("prst") == Some("textNoShape")); let kind = match prst { + _ if word_art => ShapeKind::WordArt, _ if tx_box => ShapeKind::TextBox, "roundRect" => ShapeKind::RoundedRectangle, "ellipse" => ShapeKind::Ellipse, @@ -294,6 +303,7 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat { f.italic = r.flag("i", false); f.font = r.child("latin").and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty() && !t.starts_with('+')).map(str::to_string); f.text_color = r.child("solidFill").and_then(dml_color); + f.text_shadow = r.child("effectLst").and_then(|e| e.child("outerShdw")).and_then(dml_color); f.text_outline = r.child("ln").and_then(|l| { let c = l.child("solidFill").and_then(dml_color)?; // Width in EMU (12700 per point); Excel's default hairline is 0.75 pt. @@ -520,7 +530,7 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { ShapeKind::Triangle => ("triangle", false), ShapeKind::Line | ShapeKind::Arrow => ("line", false), // Equations are written by `write_equation`; this arm keeps the match whole. - ShapeKind::TextBox | ShapeKind::Equation => ("rect", true), + ShapeKind::TextBox | ShapeKind::WordArt | ShapeKind::Equation => ("rect", true), ShapeKind::Icon | ShapeKind::Ink => ("rect", false), }; let _ = write!( @@ -550,7 +560,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { s.push_str(""); } 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\"", @@ -559,7 +570,15 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"", None => "", }; - let _ = write!(s, ""); + 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 algn = match f.align { None => "", Some(ShapeTextAlign::Left) => " algn=\"l\"", @@ -614,6 +633,12 @@ fn run_props(f: &ShapeFormat, theme: &Theme) -> (String, String) { if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) { let _ = write!(body, "{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_attr(font)); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index ab55c8445..da88df3d9 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -395,6 +395,7 @@ fn sample() -> Workbook { font: Some("Arial".into()), text_color: Some(Color::Rgb(0x112233)), text_outline: Some((Color::Rgb(0xFF0000), 1.5)), + text_shadow: Some(Color::Rgb(0x445566)), align: Some(ShapeTextAlign::Right), anchor: Some(ShapeTextAnchor::Bottom), rtl: true, diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs index dcb862582..43a3f699f 100644 --- a/crates/xlsx/src/tests/shape_text_rtl.rs +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -86,6 +86,72 @@ fn shape_text_without_rtl_writes_unchanged_ppr() { assert!(!xml.contains(""#)); + assert_eq!(xml.matches(r#""#).count(), 2); + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let shape = &reopened.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.kind, ShapeKind::WordArt); + assert!(shape.format.rtl); + assert_eq!(shape.format.align, Some(ShapeTextAlign::Right)); + assert_eq!(shape.text, "مرحبا\nبالعالم"); +} + +#[test] +fn word_art_with_empty_text_keeps_its_kind() { + let mut wb = text_box("", ShapeFormat::default()); + wb.sheet_mut(0).unwrap().shapes[0].kind = ShapeKind::WordArt; + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); +} + +#[test] +fn a_text_warp_on_a_filled_ellipse_does_not_make_it_word_art() { + let drawing = ARABIC_TEXT_BOX + .replace(r#""#, "") + .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); +} + +/// `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); +} + /// #2170: a shape font typeface holding `"`, `<` or `&` is escaped as an attribute, so it can't /// close the `typeface` attribute early and inject others; it survives a save and reopen. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 8b5f088ad..d2c89d86a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -155,7 +155,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 17. Insert objects (F) - Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their identity/artwork on save (#551); native Office InkML import is not implemented. -- SmartArt, WordArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170; +- SmartArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170; pictures in cells (#61, PR #105). Equations exist (LaTeX source with a live preview, Office Math in xlsx); there is no structured (WYSIWYG) equation editing and no ink equations. **Estimate:** 25–40 h. 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) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ac4ea015..a2243bc92 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -90,7 +90,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer, Sensitivity and Limits reports; Load/Save Model, Reset All and Solver Options; no Population report), Forecast Sheet. Missing: Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | -| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, WordArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | +| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, WordArt (10 own styles; no warps or gradient fills), own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | | Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog, a Ctrl+Shift shortcut or a form button (which can also run one command). VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | |