mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-15 18:07:35 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fc45b7f6d8
commit
470968fcdc
+3
-1
@@ -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 |
|
||||
|
||||
@@ -1048,9 +1048,12 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
if shape.kind != ShapeKind::TextBox {
|
||||
if !matches!(shape.kind, ShapeKind::TextBox | ShapeKind::WordArt) {
|
||||
return Err(bad("shape.setText", "the shape is not a text box"));
|
||||
}
|
||||
if shape.kind == ShapeKind::WordArt && text.is_empty() {
|
||||
return Err(bad("shape.setText", "WordArt text must not be empty"));
|
||||
}
|
||||
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Color>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// Font size in points.
|
||||
pub size: f32,
|
||||
}
|
||||
|
||||
const fn rgb(v: u32) -> Color {
|
||||
Color::Rgb(v)
|
||||
}
|
||||
|
||||
/// GridCraft's own WordArt presets. Theme colours where it makes sense (they follow the theme),
|
||||
/// fixed colours for the rest.
|
||||
pub const STYLES: [WordArtStyle; 10] = [
|
||||
WordArtStyle { name: "Classic", fill: Color::Theme(4, 0), outline: None, shadow: None, bold: true, italic: false, size: 36.0 },
|
||||
WordArtStyle { name: "Midnight", fill: rgb(0x1B2A49), outline: None, shadow: Some(rgb(0x8A94A6)), bold: true, italic: false, size: 40.0 },
|
||||
WordArtStyle { name: "Sunrise", fill: rgb(0xF2A33A), outline: Some((rgb(0xB5451B), 1.5)), shadow: None, bold: true, italic: false, size: 40.0 },
|
||||
WordArtStyle {
|
||||
name: "Lagoon",
|
||||
fill: rgb(0x1F9E9A),
|
||||
outline: Some((rgb(0x0B4F5C), 1.0)),
|
||||
shadow: Some(rgb(0x9CC9C7)),
|
||||
bold: true,
|
||||
italic: true,
|
||||
size: 36.0,
|
||||
},
|
||||
WordArtStyle { name: "Ember", fill: rgb(0xD64527), outline: None, shadow: Some(rgb(0x5A1A0E)), bold: true, italic: false, size: 44.0 },
|
||||
WordArtStyle {
|
||||
name: "Mint Outline",
|
||||
fill: rgb(0xFFFFFF),
|
||||
outline: Some((rgb(0x2E9B6A), 2.0)),
|
||||
shadow: None,
|
||||
bold: true,
|
||||
italic: false,
|
||||
size: 40.0,
|
||||
},
|
||||
WordArtStyle { name: "Ink Stamp", fill: rgb(0x2F3A8F), outline: Some((rgb(0x2F3A8F), 0.5)), shadow: None, bold: true, italic: false, size: 32.0 },
|
||||
WordArtStyle { name: "Orchid", fill: rgb(0x8E4FB5), outline: None, shadow: Some(rgb(0xD9C2E8)), bold: false, italic: true, size: 40.0 },
|
||||
WordArtStyle {
|
||||
name: "Graphite",
|
||||
fill: rgb(0x4A4F57),
|
||||
outline: Some((rgb(0xFFFFFF), 1.0)),
|
||||
shadow: Some(rgb(0x9AA0A8)),
|
||||
bold: true,
|
||||
italic: false,
|
||||
size: 36.0,
|
||||
},
|
||||
WordArtStyle {
|
||||
name: "Lime Pop",
|
||||
fill: rgb(0xA6D61C),
|
||||
outline: Some((rgb(0x3F5A00), 1.5)),
|
||||
shadow: Some(rgb(0x6E8B2C)),
|
||||
bold: true,
|
||||
italic: true,
|
||||
size: 40.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// The text of a WordArt inserted without `text`.
|
||||
pub const DEFAULT_TEXT: &str = "Your text here";
|
||||
/// Longest WordArt text (characters).
|
||||
pub const MAX_CHARS: usize = 1000;
|
||||
const MAX_POINTS: f64 = 400.0;
|
||||
const MAX_SIDE: f64 = 4000.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.wordArt",
|
||||
"WordArt",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{style?: preset name or 0-based index (Classic, Midnight, Sunrise, Lagoon, Ember, Mint Outline, Ink Stamp, Orchid, Graphite, Lime Pop), text?, at?, width?, height?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
|
||||
has_doc,
|
||||
insert_word_art
|
||||
),
|
||||
cmd!(
|
||||
"wordArt.set",
|
||||
"Format WordArt",
|
||||
[],
|
||||
None,
|
||||
"{id: WordArt id, style?, text?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
|
||||
has_doc,
|
||||
set_word_art
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn style_param(command: &str, p: &Json) -> Result<Option<&'static WordArtStyle>> {
|
||||
match p.get("style") {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::Number(n)) => n
|
||||
.as_u64()
|
||||
.and_then(|i| STYLES.get(usize::try_from(i).ok()?))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, format!("`style` index must be 0..{}", STYLES.len() - 1))),
|
||||
Some(Json::String(name)) => STYLES
|
||||
.iter()
|
||||
.find(|s| s.name.eq_ignore_ascii_case(name.trim()))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, format!("unknown WordArt style `{name}`"))),
|
||||
Some(_) => Err(bad(command, "`style` must be a preset name or index")),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_param(command: &str, p: &Json) -> Result<Option<String>> {
|
||||
match p.get("text") {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(t)) if t.is_empty() => Err(bad(command, "`text` must not be empty")),
|
||||
Some(Json::String(t)) if t.chars().nth(MAX_CHARS).is_some() => Err(bad(command, format!("`text` is longer than {MAX_CHARS} characters"))),
|
||||
Some(Json::String(t)) => Ok(Some(t.clone())),
|
||||
Some(_) => Err(bad(command, "`text` must be a string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a preset, then explicit overrides, to a shape's format and fill.
|
||||
fn apply(command: &str, shape: &mut Shape, style: Option<&WordArtStyle>, p: &Json) -> Result<()> {
|
||||
if let Some(st) = style {
|
||||
shape.format.text_color = Some(st.fill);
|
||||
shape.format.text_outline = st.outline;
|
||||
shape.format.text_shadow = st.shadow;
|
||||
shape.format.bold = st.bold;
|
||||
shape.format.italic = st.italic;
|
||||
shape.format.font_size = Some(st.size);
|
||||
}
|
||||
let f = &mut shape.format;
|
||||
if let Some(c) = p.get("fill").filter(|v| !v.is_null()) {
|
||||
f.text_color = Some(super::format::color_param(Some(c)).ok_or_else(|| bad(command, "`fill` must be a hex colour"))?);
|
||||
}
|
||||
match p.get("outline") {
|
||||
None | Some(Json::Null) => {}
|
||||
Some(Json::Bool(false)) => f.text_outline = None,
|
||||
Some(v) => {
|
||||
let c = super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`outline` must be a hex colour or false"))?;
|
||||
let w = f64_param(p, "outlineWidth").map_or(f.text_outline.map_or(1.5, |o| o.1), |w| w.clamp(0.25, 20.0) as f32);
|
||||
f.text_outline = Some((c, w));
|
||||
}
|
||||
}
|
||||
if let (Some(w), Some((c, _))) = (f64_param(p, "outlineWidth"), f.text_outline) {
|
||||
f.text_outline = Some((c, w.clamp(0.25, 20.0) as f32));
|
||||
}
|
||||
match p.get("shadow") {
|
||||
None | Some(Json::Null) => {}
|
||||
Some(Json::Bool(false)) => f.text_shadow = None,
|
||||
Some(Json::Bool(true)) => f.text_shadow = Some(f.text_shadow.unwrap_or(rgb(0x808080))),
|
||||
Some(v) => {
|
||||
f.text_shadow = Some(super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`shadow` must be a hex colour or a boolean"))?)
|
||||
}
|
||||
}
|
||||
if let Some(b) = bool_param(p, "bold") {
|
||||
f.bold = b;
|
||||
}
|
||||
if let Some(b) = bool_param(p, "italic") {
|
||||
f.italic = b;
|
||||
}
|
||||
if let Some(sz) = f64_param(p, "fontSize") {
|
||||
f.font_size = Some(sz.clamp(1.0, MAX_POINTS) as f32);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_word_art(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let style = style_param("insert.wordArt", p)?.unwrap_or(&STYLES[0]);
|
||||
let text = text_param("insert.wordArt", p)?.unwrap_or_else(|| DEFAULT_TEXT.to_string());
|
||||
let d = s.doc()?;
|
||||
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<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("wordArt.set", "`id` must be a WordArt id"))?;
|
||||
let style = style_param("wordArt.set", p)?;
|
||||
let text = text_param("wordArt.set", p)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
let current = sh.shapes.iter().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
if current.kind != ShapeKind::WordArt {
|
||||
return Err(bad("wordArt.set", "the shape is not WordArt"));
|
||||
}
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let mut updated = current.clone();
|
||||
apply("wordArt.set", &mut updated, style, p)?;
|
||||
if let Some(t) = text {
|
||||
updated.text = t;
|
||||
}
|
||||
if &updated == current {
|
||||
return ok();
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
*shape = updated;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Color>,
|
||||
/// Text outline (`a:rPr/a:ln`): colour and width in points.
|
||||
pub text_outline: Option<(Color, f32)>,
|
||||
/// Text drop shadow colour (`a:rPr/a:effectLst/a:outerShdw`); `None` is no shadow.
|
||||
pub text_shadow: Option<Color>,
|
||||
/// `None`: the shape's default (left for text boxes, centred for other shapes).
|
||||
pub align: Option<ShapeTextAlign>,
|
||||
/// `None`: the shape's default (top for text boxes, middle for other shapes).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -272,6 +272,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Shapes", "図形"),
|
||||
("Icons", "アイコン"),
|
||||
("Text\nBox", "テキスト ボックス"),
|
||||
("Word\nArt", "ワードアート"),
|
||||
("Insert WordArt", "ワードアートの挿入"),
|
||||
("Comment", "コメント"),
|
||||
("New\nComment", "新しいコメント"),
|
||||
("New Note", "新しいメモ"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => "새 메모",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => "Новая заметка",
|
||||
|
||||
@@ -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" => "新建备注",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod tabs;
|
||||
pub mod text_box;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
pub mod wordart;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -199,7 +199,10 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
|
||||
let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false));
|
||||
let ln = sp.child("ln");
|
||||
let arrow = ln.is_some_and(|l| l.child("tailEnd").or_else(|| l.child("headEnd")).is_some_and(|t| t.attr("type").is_some_and(|v| v != "none")));
|
||||
// WordArt: 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, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
|
||||
if sh.kind == ShapeKind::WordArt {
|
||||
// Our WordArt marker: a (non-warping) text warp, which the reader maps back to WordArt.
|
||||
let _ = write!(
|
||||
s,
|
||||
"<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}><a:prstTxWarp prst=\"textNoShape\"><a:avLst/></a:prstTxWarp></a:bodyPr><a:lstStyle/>"
|
||||
);
|
||||
} else {
|
||||
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
|
||||
}
|
||||
let ppr = match f.align {
|
||||
None => "",
|
||||
Some(ShapeTextAlign::Left) => "<a:pPr algn=\"l\"/>",
|
||||
@@ -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, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
if let Some(c) = f.text_shadow.as_ref().and_then(|c| srgb(c, theme)) {
|
||||
let _ = write!(
|
||||
body,
|
||||
"<a:effectLst><a:outerShdw blurRad=\"38100\" dist=\"38100\" dir=\"2700000\" algn=\"tl\" rotWithShape=\"0\">{c}</a:outerShdw></a:effectLst>"
|
||||
);
|
||||
}
|
||||
if let Some(font) = &f.font {
|
||||
let _ = write!(body, "<a:latin typeface=\"{}\"/>", esc(font));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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** | |
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user