Insert > Icons: an original icon library, a searchable picker and Icon Color (#309) (#318)

Adds wordcraft_render::icon_lib, 61 monochrome icons in ten categories
(Arrows, Communication, Office, People, Nature, Weather, Shapes, Symbols,
Technology, Transport). They are original WordCraft artwork: simple
geometric line drawings designed on a 24x24 grid as path data in code, with
no icon files and nothing copied from another icon set.

insert.icon {"id", "color"?, "size"?} renders the chosen icon with vello_cpu
to a transparent PNG at about 300 ppi and inserts it at the caret through the
ordinary picture path, so it saves to .docx and every other format. Without
an id it lists the library (narrowed by query and category) and asks the
front end for the picker. picture.iconColor repaints a one-colour picture
in a new colour and keeps its transparency.

Insert > Illustrations > Icons opens a dialog with a search box, category
chips and a grid of icons drawn live by the egui painter from the same path
data; Insert or a double click places the selection. Picture Format > Adjust
gains an Icon Color menu. New strings are translated in every catalog.

Catalog parity 393/434 -> 394/434 (90.8%).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 19:27:42 +02:00 committed by GitHub
parent a5b17eb3a4
commit 8a2a8bb60d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1084 additions and 19 deletions

View File

@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed | | Date | What landed |
|---|---| |---|---|
| 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color |
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field | | 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip | | 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) | | 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) |
@ -156,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) | | 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) | | 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |

View File

@ -25,6 +25,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.coverPage", "Cover Page", "Insert › Pages", cover_page).params(r#"{"title"?: string, "subtitle"?: string, "author"?: string}"#), CommandSpec::new("insert.coverPage", "Cover Page", "Insert › Pages", cover_page).params(r#"{"title"?: string, "subtitle"?: string, "author"?: string}"#),
CommandSpec::new("insert.table", "Table", "Insert › Tables", table).params(r#"{"rows": n, "cols": n, "style"?: string}"#), CommandSpec::new("insert.table", "Table", "Insert › Tables", table).params(r#"{"rows": n, "cols": n, "style"?: string}"#),
CommandSpec::new("insert.picture", "Pictures", "Insert › Illustrations", picture).params(r#"{"path"?: string, "data"?: base64, "width"?: pt, "alt"?: string}"#), CommandSpec::new("insert.picture", "Pictures", "Insert › Illustrations", picture).params(r#"{"path"?: string, "data"?: base64, "width"?: pt, "alt"?: string}"#),
CommandSpec::new("insert.icon", "Icons", "Insert › Illustrations", icon)
.params(r#"{"id"?: string (omit to list the library), "color"?: "RRGGBB", "size"?: pt, "query"?: string, "category"?: string}"#),
CommandSpec::new("insert.shape", "Shapes", "Insert › Illustrations", shape) CommandSpec::new("insert.shape", "Shapes", "Insert › Illustrations", shape)
.params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB"}"#), .params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB"}"#),
CommandSpec::new("insert.textBox", "Text Box", "Insert › Text", text_box) CommandSpec::new("insert.textBox", "Text Box", "Insert › Text", text_box)
@ -206,6 +208,12 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
s.ui_requests.push(json!({"open": "insertPicture"})); s.ui_requests.push(json!({"open": "insertPicture"}));
return sel_result(s); return sel_result(s);
}; };
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
}
/// Insert an encoded image at the selection as an inline picture, `width` points wide (default:
/// its natural size at 96 ppi), never wider than the text column.
fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str) -> CmdResult {
if bytes.len() > 200 << 20 { if bytes.len() > 200 << 20 {
return Err(CmdError::Failed("image is larger than 200 MB".into())); return Err(CmdError::Failed("image is larger than 200 MB".into()));
} }
@ -223,7 +231,7 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
// Natural size at 96 ppi, fitted to the text width. // Natural size at 96 ppi, fitted to the text width.
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75); let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75);
if let Some(want) = p::f32(v, "width") { if let Some(want) = width {
let k = want.max(4.0) / w.max(1.0); let k = want.max(4.0) / w.max(1.0);
w *= k; w *= k;
h *= k; h *= k;
@ -234,20 +242,44 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
} }
let props = s.typing_props(); let props = s.typing_props();
let at = delete_selection(s)?; let at = delete_selection(s)?;
let obj = InlineObject::Image { let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
media: key.clone(),
w,
h,
alt: p::str(v, "alt").unwrap_or("").to_string(),
float: Float::default(),
crop: [0.0; 4],
ole: None,
};
let end = s.doc.insert_object(&at, obj, &props)?; let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection { anchor: at, focus: end }; s.sel = Selection { anchor: at, focus: end };
Ok(json!({"media": key, "width": w, "height": h})) Ok(json!({"media": key, "width": w, "height": h}))
} }
/// Insert › Icons: a library icon (original artwork drawn in code, `wordcraft_render::icon_lib`)
/// rendered to a PNG picture `size` points square (default 1 inch) in `color` (default black).
/// Without an `id` it lists the library (narrowed by `query` and `category`) and asks a front end
/// for its icon picker.
fn icon(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_render::icon_lib;
let Some(id) = p::str(v, "id") else {
let category = p::str(v, "category").and_then(icon_lib::Category::parse);
let icons: Vec<Value> = icon_lib::search(p::str(v, "query").unwrap_or(""), category)
.iter()
.map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords}))
.collect();
s.ui_requests.push(json!({"open": "icons"}));
return Ok(json!({"icons": icons}));
};
let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?;
let ink = match p::str(v, "color") {
Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params(format!("bad color {c:?}: use RRGGBB")))?,
None => Rgb::BLACK,
};
let size = p::f32(v, "size").unwrap_or(72.0).clamp(4.0, 1584.0);
// About 300 pixels per inch, so it prints sharply.
let px = (size * 300.0 / 72.0).round().clamp(64.0, 1024.0) as u32;
let png = icon_lib::render(icon, px, ink).to_png();
if png.is_empty() {
return Err(CmdError::Failed("couldn't draw the icon".into()));
}
let mut r = place_picture(s, png, Some(size), icon.name)?;
r["icon"] = json!(icon.id);
Ok(r)
}
fn shape(s: &mut Session, v: &Value) -> CmdResult { fn shape(s: &mut Session, v: &Value) -> CmdResult {
let kind: ShapeKind = let kind: ShapeKind =
serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?; serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;

View File

@ -106,6 +106,20 @@ pub fn specs() -> Vec<CommandSpec> {
}) })
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#) .params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture), .when(has_picture),
CommandSpec::new("picture.iconColor", "Icon Color", "Picture Format › Adjust", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse).ok_or_else(|| CmdError::Params("`color` (RRGGBB) is required".into()))?;
// One colour everywhere, transparency kept: recolours an inserted icon or other
// one-colour line art without redrawing it.
adjust(s, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0 = [c.0, c.1, c.2, px.0[3]];
}
image::DynamicImage::ImageRgba8(rgba)
})
})
.params(r#"{"color": "RRGGBB"}"#)
.when(has_picture),
CommandSpec::new("picture.effects", "Artistic Effects", "Picture Format › Adjust", |s, v| { CommandSpec::new("picture.effects", "Artistic Effects", "Picture Format › Adjust", |s, v| {
let e = p::str(v, "effect").unwrap_or("blur").to_string(); let e = p::str(v, "effect").unwrap_or("blur").to_string();
adjust(s, move |img| match e.as_str() { adjust(s, move |img| match e.as_str() {

View File

@ -2808,6 +2808,40 @@ fn deleting_the_last_cell_column_never_leaves_a_table_without_rows() {
assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty()))); assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty())));
} }
#[test]
fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours() {
use wordcraft_doc::para::InlineObject;
let mut s = s();
// Without an id: the library is listed and a front end is asked for its picker.
let r = run(&mut s, "insert.icon", json!({"query": "envelope"}));
assert_eq!(r["icons"][0]["id"], "mail");
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "icons"})));
let all = run(&mut s, "insert.icon", json!({}));
assert!(all["icons"].as_array().unwrap().len() >= 40);
assert!(s.run("insert.icon", &json!({"id": "no-such-icon"})).is_err());
assert!(s.run("insert.icon", &json!({"id": "heart", "color": "red"})).is_err());
let r = run(&mut s, "insert.icon", json!({"id": "heart", "color": "C00000", "size": 48}));
assert_eq!((r["width"].as_f64(), r["height"].as_f64(), r["icon"].as_str()), (Some(48.0), Some(48.0), Some("heart")));
let Some((_, InlineObject::Image { media, w, h, alt, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
assert_eq!((w, h, alt.as_str()), (48.0, 48.0, "Heart"));
let png = image::load_from_memory(&s.doc.media[&media]).unwrap().to_rgba8();
assert_eq!(png.dimensions(), (200, 200), "about 300 pixels per inch");
let inked: Vec<_> = png.pixels().filter(|p| p.0[3] == 255).collect();
assert!(!inked.is_empty() && inked.iter().all(|p| p.0[..3] == [0xC0, 0, 0]));
assert!(png.get_pixel(0, 0).0[3] == 0, "transparent background");
// It is an ordinary picture: it saves to .docx.
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(back.media.len(), 1);
// Icon Color repaints it, keeping the transparency.
run(&mut s, "picture.iconColor", json!({"color": "1F4E79"}));
let Some((_, InlineObject::Image { media: m2, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
let png2 = image::load_from_memory(&s.doc.media[&m2]).unwrap().to_rgba8();
assert!(png2.pixels().zip(png.pixels()).all(|(a, b)| a.0[3] == b.0[3] && (a.0[3] == 0 || a.0[..3] == [0x1F, 0x4E, 0x79])));
assert!(s.run("picture.iconColor", &json!({})).is_err());
}
/// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the /// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the
/// way Word stores it (the style in the code's run sizes) and read back; null takes the text out /// way Word stores it (the style in the code's run sizes) and read back; null takes the text out
/// again, and each step undoes. /// again, and each step undoes.

View File

@ -0,0 +1,731 @@
//! The Insert › Icons library: monochrome pictograms drawn in code.
//!
//! Every icon here is original WordCraft artwork: simple geometric line drawings designed on a
//! 24×24 grid for this file (no external icon set was copied, traced or adapted). The same path
//! data is drawn live by the interface's icon picker and rasterised here, by vello_cpu, into the
//! PNG picture `insert.icon` places in the document.
//!
//! Path data is a small SVG-like language in grid units (y grows downwards), absolute only:
//! - `M x y` move, `L x y` line (more pairs repeat the line), `Q cx cy x y` quadratic curve,
//! `C c1x c1y c2x c2y x y` cubic curve, `Z` close the subpath;
//! - `A cx cy r a0 a1` circular arc from angle `a0` to `a1` in degrees (0° points right, 90°
//! down), joined to the open subpath by a line, or starting one;
//! - `O cx cy r` a whole circle and `R x y w h r` a rounded rectangle, each its own closed subpath.
//!
//! Stroked parts are drawn with a [`STROKE`]-unit pen with round caps and joins. Filled parts are
//! convex outlines (so any front end can fill them as simple polygons).
use std::sync::OnceLock;
use vello_cpu::kurbo::{self, Affine, BezPath};
use vello_cpu::{RenderContext, Resources};
use wordcraft_doc::props::Rgb;
use crate::Rendered;
/// Side of the design grid, in grid units.
pub const GRID: f32 = 24.0;
/// Pen width of stroked parts, in grid units.
pub const STROKE: f32 = 2.0;
/// The library's categories, in the order the picker shows them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Category {
Arrows,
Communication,
Office,
People,
Nature,
Weather,
Shapes,
Symbols,
Technology,
Transport,
}
impl Category {
pub const ALL: [Category; 10] = [
Category::Arrows,
Category::Communication,
Category::Office,
Category::People,
Category::Nature,
Category::Weather,
Category::Shapes,
Category::Symbols,
Category::Technology,
Category::Transport,
];
/// English name (also the interface catalogs' source text).
pub fn name(self) -> &'static str {
match self {
Category::Arrows => "Arrows",
Category::Communication => "Communication",
Category::Office => "Office",
Category::People => "People",
Category::Nature => "Nature",
Category::Weather => "Weather",
Category::Shapes => "Shapes",
Category::Symbols => "Symbols",
Category::Technology => "Technology",
Category::Transport => "Transport",
}
}
/// The category named `name` (case-insensitive).
pub fn parse(name: &str) -> Option<Category> {
Category::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(name.trim()))
}
}
/// One drawing step of an icon.
#[derive(Clone, Copy, Debug)]
pub enum Part {
/// Path data stroked with the pen.
Stroke(&'static str),
/// Path data filled; every subpath is convex.
Fill(&'static str),
}
/// A library icon.
#[derive(Debug)]
pub struct Icon {
/// Stable id (`insert.icon`'s `id`).
pub id: &'static str,
/// English name; also the inserted picture's alt text.
pub name: &'static str,
pub category: Category,
/// Extra search words.
pub keywords: &'static [&'static str],
pub parts: &'static [Part],
}
/// A flattened subpath in grid units.
#[derive(Clone, Debug, PartialEq)]
pub struct Piece {
pub points: Vec<[f32; 2]>,
pub closed: bool,
pub fill: bool,
}
use Category::*;
use Part::{Fill as F, Stroke as S};
macro_rules! icon {
($id:literal, $name:literal, $cat:expr, [$($k:literal),*], [$($p:expr),* $(,)?]) => {
Icon { id: $id, name: $name, category: $cat, keywords: &[$($k),*], parts: &[$($p),*] }
};
}
/// The library, grouped by category.
pub static ICONS: &[Icon] = &[
// Arrows
icon!("arrow-right", "Arrow Right", Arrows, ["next", "forward", "east"], [S("M3.5 12 L20 12 M14 6 L20 12 L14 18")]),
icon!("arrow-left", "Arrow Left", Arrows, ["back", "previous", "west"], [S("M20.5 12 L4 12 M10 6 L4 12 L10 18")]),
icon!("arrow-up", "Arrow Up", Arrows, ["north", "increase", "top"], [S("M12 20.5 L12 4 M6 10 L12 4 L18 10")]),
icon!("arrow-down", "Arrow Down", Arrows, ["south", "decrease", "bottom", "download"], [S("M12 3.5 L12 20 M6 14 L12 20 L18 14")]),
icon!("refresh", "Refresh", Arrows, ["reload", "repeat", "cycle", "rotate"], [S("A12 12 7.5 -45 225"), S("M2.7 6.7 L6.7 6.7 L6.7 10.7"),]),
icon!("swap", "Swap", Arrows, ["exchange", "transfer", "trade"], [S("M4 8 L19 8 M15 4 L19 8 L15 12 M20 16 L5 16 M9 12 L5 16 L9 20")]),
// Communication
icon!("mail", "Mail", Communication, ["email", "envelope", "letter", "message"], [S("R2.5 5 19 14 2 M3.5 6.5 L12 13 L20.5 6.5")]),
icon!(
"chat",
"Chat",
Communication,
["speech", "bubble", "talk", "comment", "message"],
[S(
"M5 3.5 L19 3.5 Q21.5 3.5 21.5 6 L21.5 13.5 Q21.5 16 19 16 L11 16 L6 20.5 L6 16 L5 16 Q2.5 16 2.5 13.5 L2.5 6 Q2.5 3.5 5 3.5 Z M7 8 L17 8 M7 11.5 L14 11.5"
)]
),
icon!(
"megaphone",
"Megaphone",
Communication,
["announce", "loud", "marketing", "news"],
[S("M3 9.5 L3 14.5 L7 14.5 L17 19.5 L17 4.5 L7 9.5 Z M7 9.5 L7 14.5 M7 14.5 L8.5 20.5 L11.5 20.5 L10.5 16 M20 9.5 Q21.5 12 20 14.5")]
),
icon!(
"bell",
"Bell",
Communication,
["notification", "alert", "ring", "reminder"],
[S("M5 17 L19 17 M7 17 L7 11 Q7 5.5 12 5.5 Q17 5.5 17 11 L17 17 M12 3 L12 5.5 M10 20.5 L14 20.5")]
),
icon!(
"inbox",
"Inbox",
Communication,
["tray", "receive", "messages"],
[S("M2.5 13 L2.5 19.5 L21.5 19.5 L21.5 13 L16 13 L15 15.5 L9 15.5 L8 13 Z M5.5 13 L7.5 4.5 L16.5 4.5 L18.5 13")]
),
// Office
icon!(
"document",
"Document",
Office,
["page", "file", "paper", "text"],
[S("M6 2.5 L14 2.5 L19 7.5 L19 21.5 L6 21.5 Z M14 2.5 L14 7.5 L19 7.5 M9 12 L16 12 M9 15 L16 15 M9 18 L13 18")]
),
icon!(
"folder",
"Folder",
Office,
["directory", "files", "archive"],
[S("M2.5 5 L9 5 L11 7.5 L21.5 7.5 L21.5 19.5 L2.5 19.5 Z M2.5 10.5 L21.5 10.5")]
),
icon!(
"calendar",
"Calendar",
Office,
["date", "schedule", "month", "event"],
[S("R3 5 18 16 2 M3 10 L21 10 M8 3 L8 7 M16 3 L16 7"), F("O8 13.8 1.1 O12 13.8 1.1 O16 13.8 1.1 O8 17.4 1.1 O12 17.4 1.1"),]
),
icon!(
"clipboard",
"Clipboard",
Office,
["list", "tasks", "notes", "checklist"],
[S("R5 4.5 14 17 2 R9 2.5 6 4 1 M9 11 L15 11 M9 14.5 L15 14.5 M9 18 L13 18")]
),
icon!(
"pencil",
"Pencil",
Office,
["edit", "write", "draw", "pen"],
[S("M4 20 L5 15 L15.5 4.5 Q16.5 3.5 17.5 4.5 L19.5 6.5 Q20.5 7.5 19.5 8.5 L9 19 Z M13.5 6.5 L17.5 10.5 M5 15 L9 19")]
),
icon!(
"briefcase",
"Briefcase",
Office,
["work", "business", "job", "case"],
[S("R2.5 7 19 13 2 M9 7 L9 4.5 L15 4.5 L15 7 M2.5 13 L21.5 13"), F("R10.5 11.5 3 3 0.5"),]
),
icon!(
"bar-chart",
"Bar Chart",
Office,
["graph", "statistics", "report", "data"],
[S("M3 21 L21 21"), F("R5.5 12 3 7 0.5 R10.5 6 3 13 0.5 R15.5 9 3 10 0.5"),]
),
// People
icon!("person", "Person", People, ["user", "account", "profile", "human"], [S("O12 7.5 4 M4 21 Q4 13.5 12 13.5 Q20 13.5 20 21")]),
icon!(
"group",
"Group",
People,
["team", "users", "people", "friends"],
[S("O9 8 3.5 M2.5 20 Q2.5 14 9 14 Q15.5 14 15.5 20 O16.5 7 3 M17 12.8 Q21.5 12.8 21.5 18")]
),
icon!(
"smiley",
"Smiley",
People,
["face", "happy", "smile", "emotion"],
[S("O12 12 9.5 M8.2 14.2 Q12 18.5 15.8 14.2"), F("O9 9.8 1.3 O15 9.8 1.3"),]
),
icon!(
"person-add",
"Add Person",
People,
["invite", "new user", "join"],
[S("O10 7.5 4 M3 20.5 Q3 13.5 10 13.5 Q12.8 13.5 14.6 14.6 M19 13 L19 19 M16 16 L22 16")]
),
icon!(
"badge",
"Name Badge",
People,
["id", "identity", "card", "employee"],
[S("R4 2.5 16 19 2 O12 10 3 M8 17.5 Q12 13.5 16 17.5 M10 5.5 L14 5.5")]
),
// Nature
icon!(
"leaf",
"Leaf",
Nature,
["plant", "eco", "green", "growth"],
[S("M4.5 19.5 Q4.5 5 19.5 4.5 Q19.5 19.5 4.5 19.5 Z M4.5 19.5 L13 11 M3 21 L4.5 19.5")]
),
icon!(
"tree",
"Pine Tree",
Nature,
["forest", "fir", "evergreen", "winter"],
[S("M12 2.5 L18 10 L15 10 L19.5 16.5 L4.5 16.5 L9 10 L6 10 Z M12 16.5 L12 21.5")]
),
icon!(
"flower",
"Tulip",
Nature,
["flower", "spring", "garden", "bloom"],
[S(
"M7 5 L9.5 8 L12 4 L14.5 8 L17 5 L17 10 Q17 14.5 12 14.5 Q7 14.5 7 10 Z M12 14.5 L12 21.5 M12 18.5 Q8 18.5 6.5 15.5 M12 19.5 Q15.5 19.5 17 17"
)]
),
icon!("mountain", "Mountain", Nature, ["landscape", "peak", "hiking", "outdoors"], [S("M2 20 L9 8 L13 14 L16 10 L22 20 Z"), F("O18.5 5 2"),]),
icon!("paw", "Paw Print", Nature, ["pet", "animal", "dog", "cat"], [F("O12 16.5 3.6 O6.5 11 1.9 O10 7 1.9 O14 7 1.9 O17.5 11 1.9")]),
// Weather
icon!(
"sun",
"Sun",
Weather,
["sunny", "bright", "day", "summer"],
[S(
"O12 12 4 M12 3 L12 5.5 M12 18.5 L12 21 M3 12 L5.5 12 M18.5 12 L21 12 M5.64 5.64 L7.4 7.4 M16.6 16.6 L18.36 18.36 M5.64 18.36 L7.4 16.6 M16.6 7.4 L18.36 5.64"
)]
),
icon!("cloud", "Cloud", Weather, ["cloudy", "overcast", "storage"], [S("M7 18 A7 14.5 3.5 90 270 A12 11 5 180 360 A17 14.5 3.5 270 450 Z")]),
icon!(
"rain",
"Rain",
Weather,
["rainy", "shower", "storm", "wet"],
[S("M7 15 A7 11.5 3.5 90 270 A12 8 5 180 360 A17 11.5 3.5 270 450 Z M8 18 L7 21 M12 18 L11 21 M16 18 L15 21")]
),
icon!(
"lightning",
"Lightning",
Weather,
["bolt", "thunder", "energy", "flash"],
[S("M13.5 2.5 L5 13.5 L11.5 13.5 L10.5 21.5 L19 10 L12.5 10 Z")]
),
icon!("moon", "Moon", Weather, ["night", "crescent", "sleep", "dark"], [S("M18 5.07 A14 12 8 -60 -300 A18 12 6.93 90 270 Z")]),
icon!(
"snowflake",
"Snowflake",
Weather,
["snow", "cold", "winter", "frozen"],
[S(
"M12 2.5 L12 21.5 M20.23 7.25 L3.77 16.75 M20.23 16.75 L3.77 7.25 M14.4 3.9 L12 6.5 L9.6 3.9 M20.21 10.03 L16.76 9.25 L17.81 5.87 M17.81 18.13 L16.76 14.75 L20.21 13.97 M9.6 20.1 L12 17.5 L14.4 20.1 M3.79 13.97 L7.24 14.75 L6.19 18.13 M6.19 5.87 L7.24 9.25 L3.79 10.03"
)]
),
icon!(
"droplet",
"Droplet",
Weather,
["water", "drop", "liquid", "humidity"],
[S("M12 2.5 C12 2.5 18.5 10 18.5 14.5 C18.5 18.5 15.5 21.5 12 21.5 C8.5 21.5 5.5 18.5 5.5 14.5 C5.5 10 12 2.5 12 2.5 Z")]
),
// Shapes
icon!("circle", "Circle", Shapes, ["round", "ring", "dot"], [S("O12 12 9")]),
icon!("square", "Square", Shapes, ["box", "rectangle"], [S("R3.5 3.5 17 17 2.5")]),
icon!("triangle", "Triangle", Shapes, ["delta", "pyramid"], [S("M12 3.5 L21 19.5 L3 19.5 Z")]),
icon!(
"star",
"Star",
Shapes,
["favorite", "rating", "award"],
[S("M12 3 L14.29 9.35 L21.04 9.56 L15.71 13.71 L17.58 20.19 L12 16.4 L6.42 20.19 L8.29 13.71 L2.96 9.56 L9.71 9.35 Z")]
),
icon!("hexagon", "Hexagon", Shapes, ["polygon", "honeycomb", "cell"], [S("M12 3 L19.79 7.5 L19.79 16.5 L12 21 L4.21 16.5 L4.21 7.5 Z")]),
// Symbols
icon!(
"heart",
"Heart",
Symbols,
["love", "like", "favorite", "health"],
[S(
"M12 20 C5 15 2.5 11.5 2.5 8.5 C2.5 5.8 4.7 3.8 7.3 3.8 C9.4 3.8 11 5 12 6.8 C13 5 14.6 3.8 16.7 3.8 C19.3 3.8 21.5 5.8 21.5 8.5 C21.5 11.5 19 15 12 20 Z"
)]
),
icon!("check", "Check Mark", Symbols, ["done", "ok", "yes", "complete", "tick"], [S("O12 12 9.5 M7.8 12.3 L10.8 15.3 L16.3 9.3")]),
icon!("plus", "Plus", Symbols, ["add", "new", "more", "positive"], [S("M12 4.5 L12 19.5 M4.5 12 L19.5 12")]),
icon!("info", "Information", Symbols, ["info", "about", "help", "details"], [S("O12 12 9.5 M12 10.5 L12 16.5"), F("O12 7.5 1.25")]),
icon!(
"warning",
"Warning",
Symbols,
["caution", "alert", "danger", "attention"],
[S("M12 3 L21.5 20 L2.5 20 Z M12 9.5 L12 14"), F("O12 17 1.2"),]
),
icon!(
"question",
"Question",
Symbols,
["help", "ask", "faq", "unknown"],
[S("O12 12 9.5 M9.3 9.3 Q9.3 6.8 12 6.8 Q14.7 6.8 14.7 9.3 Q14.7 11 12 12.3 L12 13.6"), F("O12 16.8 1.2"),]
),
icon!("flag", "Flag", Symbols, ["goal", "milestone", "report", "country"], [S("M5 21.5 L5 3 M5 4 L19 4 L15.5 8.5 L19 13 L5 13")]),
icon!(
"lightbulb",
"Light Bulb",
Symbols,
["idea", "tip", "insight", "innovation"],
[S("M9 17 L9 15.2 Q5.8 13.2 5.8 9.3 Q5.8 3 12 3 Q18.2 3 18.2 9.3 Q18.2 13.2 15 15.2 L15 17 Z M9.5 20.5 L14.5 20.5")]
),
// Technology
icon!(
"laptop",
"Laptop",
Technology,
["computer", "notebook", "device"],
[S("R4.5 5 15 10.5 1.5 M2.5 19 L21.5 19 M4.5 15.5 L2.5 19 M19.5 15.5 L21.5 19")]
),
icon!("mobile", "Mobile Phone", Technology, ["phone", "smartphone", "cell", "device"], [S("R6.5 2.5 11 19 2 M11 18.5 L13 18.5")]),
icon!(
"wifi",
"Wireless",
Technology,
["wifi", "signal", "network", "internet"],
[S("A12 19.5 4.5 225 315"), S("A12 19.5 9 225 315"), S("A12 19.5 13.5 225 315"), F("O12 19.5 1.4"),]
),
icon!("monitor", "Monitor", Technology, ["screen", "display", "desktop", "computer"], [S("R2.5 3.5 19 13 1.5 M12 16.5 L12 20 M8 20.5 L16 20.5")]),
icon!("battery", "Battery", Technology, ["power", "charge", "energy"], [S("R2.5 7 17 10 2 M21.5 10.5 L21.5 13.5"), F("R5 9.5 8 5 0.6")]),
icon!("code", "Code", Technology, ["programming", "brackets", "developer", "html"], [S("M8 7 L3 12 L8 17 M16 7 L21 12 L16 17 M14 4.5 L10 19.5")]),
icon!("power", "Power", Technology, ["on", "off", "switch", "shutdown"], [S("A12 13 7.5 -60 240"), S("M12 3 L12 11")]),
// Transport
icon!(
"car",
"Car",
Transport,
["auto", "vehicle", "drive", "road"],
[S("M2.5 16 L2.5 11.5 L5.5 6 L18.5 6 L21.5 11.5 L21.5 16 Z M2.5 11.5 L21.5 11.5 M12 6 L12 11.5"), F("O7 17.5 2.3 O17 17.5 2.3"),]
),
icon!(
"bicycle",
"Bicycle",
Transport,
["bike", "cycling", "ride"],
[S("O5.5 16 3.5 O18.5 16 3.5 M5.5 16 L9.5 9 L12 16 Z M9.5 9 L16 9 L12 16 M16 9 L18.5 16 M16 9 L15 6 L17.5 6 M9.5 9 L9 7 M7.5 7 L10.5 7")]
),
icon!(
"airplane",
"Airplane",
Transport,
["plane", "flight", "travel", "airport"],
[S(
"M12 2.5 Q13.5 2.5 13.5 5 L13.5 9.5 L21 14 L21 16 L13.5 13.5 L13.5 18 L16 20 L16 21.5 L12 20.5 L8 21.5 L8 20 L10.5 18 L10.5 13.5 L3 16 L3 14 L10.5 9.5 L10.5 5 Q10.5 2.5 12 2.5 Z"
)]
),
icon!(
"bus",
"Bus",
Transport,
["coach", "public transport", "transit"],
[S("R4 2.5 16 16.5 2.5 M4 11 L20 11 M7 19 L7 21.5 M17 19 L17 21.5 M8 5.5 L16 5.5"), F("O8 15 1.3 O16 15 1.3"),]
),
icon!(
"sailboat",
"Sailboat",
Transport,
["boat", "ship", "sea", "sailing"],
[S("M3 16 L21 16 L18 20.5 L6 20.5 Z M12 2.5 L12 16 M12 4 L18.5 13.5 L12 13.5 M10 6 L5.5 13.5 L10 13.5 Z")]
),
icon!(
"map-pin",
"Map Pin",
Transport,
["location", "place", "marker", "map", "travel"],
[S("M12 21.5 Q5 14 5 9.5 A12 9.5 7 180 360 Q19 14 12 21.5 Z O12 9.5 2.5")]
),
];
/// The icon with this id.
pub fn find(id: &str) -> Option<&'static Icon> {
ICONS.iter().find(|i| i.id == id.trim())
}
/// Icons matching every word of `query` (in the id, name, keywords or category; case-insensitive)
/// and, when given, in `category`. An empty query matches everything.
pub fn search(query: &str, category: Option<Category>) -> Vec<&'static Icon> {
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
ICONS
.iter()
.filter(|i| category.is_none_or(|c| i.category == c))
.filter(|i| {
words.iter().all(|w| {
i.id.contains(w.as_str())
|| i.name.to_lowercase().contains(w.as_str())
|| i.category.name().to_lowercase().contains(w.as_str())
|| i.keywords.iter().any(|k| k.contains(w.as_str()))
})
})
.collect()
}
impl Icon {
/// Parse and flatten the icon's parts.
pub fn parse(&self) -> Result<Vec<Piece>, String> {
let mut out = Vec::new();
for part in self.parts {
let (d, fill) = match part {
Part::Stroke(d) => (d, false),
Part::Fill(d) => (d, true),
};
for (points, closed) in flatten(d).map_err(|e| format!("{}: {e}", self.id))? {
out.push(Piece { points, closed: closed || fill, fill });
}
}
Ok(out)
}
/// The flattened geometry, parsed once (empty for an icon whose data doesn't parse, logged).
pub fn pieces(&self) -> &'static [Piece] {
static ALL: OnceLock<Vec<Vec<Piece>>> = OnceLock::new();
let all = ALL.get_or_init(|| {
ICONS
.iter()
.map(|i| {
i.parse().unwrap_or_else(|e| {
log::error!("icon library: {e}");
Vec::new()
})
})
.collect()
});
ICONS.iter().position(|i| i.id == self.id).and_then(|n| all.get(n)).map(Vec::as_slice).unwrap_or(&[])
}
}
#[derive(Clone, Copy, Debug)]
enum Tok {
Cmd(char),
Num(f32),
}
fn tokens(d: &str) -> Result<Vec<Tok>, String> {
let mut out = Vec::new();
let mut chars = d.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c.is_whitespace() || c == ',' {
continue;
}
if c.is_ascii_alphabetic() {
out.push(Tok::Cmd(c));
continue;
}
let mut end = i + c.len_utf8();
while let Some(&(j, n)) = chars.peek() {
if n.is_ascii_digit() || n == '.' {
end = j + n.len_utf8();
chars.next();
} else {
break;
}
}
let text = d.get(i..end).unwrap_or("");
let v: f32 = text.parse().map_err(|_| format!("bad number {text:?}"))?;
if !v.is_finite() {
return Err(format!("bad number {text:?}"));
}
out.push(Tok::Num(v));
}
Ok(out)
}
/// Flatten path data into polylines (`(points, closed)`).
fn flatten(d: &str) -> Result<Vec<(Vec<[f32; 2]>, bool)>, String> {
let toks = tokens(d)?;
let mut subs: Vec<(Vec<[f32; 2]>, bool)> = Vec::new();
let mut cur: Vec<[f32; 2]> = Vec::new();
let mut i = 0;
let mut last = None;
let flush = |cur: &mut Vec<[f32; 2]>, subs: &mut Vec<(Vec<[f32; 2]>, bool)>, closed: bool| {
if cur.len() >= 2 {
subs.push((std::mem::take(cur), closed));
}
cur.clear();
};
while i < toks.len() {
let cmd = match toks.get(i) {
Some(Tok::Cmd(c)) => {
i += 1;
*c
}
// A number repeats the previous command (a repeated move is a line).
Some(Tok::Num(_)) => match last {
Some('M') => 'L',
Some(c @ ('L' | 'Q' | 'C')) => c,
_ => return Err("number without a command".into()),
},
None => break,
};
let argc = match cmd {
'M' | 'L' => 2,
'Q' => 4,
'C' => 6,
'A' | 'R' => 5,
'O' => 3,
'Z' => 0,
c => return Err(format!("unknown command {c:?}")),
};
let mut a = [0f32; 6];
for slot in a.iter_mut().take(argc) {
match toks.get(i) {
Some(Tok::Num(v)) => *slot = *v,
_ => return Err(format!("{cmd} needs {argc} numbers")),
}
i += 1;
}
let from = cur.last().copied();
match cmd {
'M' => {
flush(&mut cur, &mut subs, false);
cur.push([a[0], a[1]]);
}
'L' => {
from.ok_or("L without a current point")?;
cur.push([a[0], a[1]]);
}
'Q' => {
let p0 = from.ok_or("Q without a current point")?;
for k in 1..=12 {
let t = k as f32 / 12.0;
let u = 1.0 - t;
cur.push([u * u * p0[0] + 2.0 * u * t * a[0] + t * t * a[2], u * u * p0[1] + 2.0 * u * t * a[1] + t * t * a[3]]);
}
}
'C' => {
let p0 = from.ok_or("C without a current point")?;
for k in 1..=16 {
let t = k as f32 / 16.0;
let u = 1.0 - t;
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
cur.push([b0 * p0[0] + b1 * a[0] + b2 * a[2] + b3 * a[4], b0 * p0[1] + b1 * a[1] + b2 * a[3] + b3 * a[5]]);
}
}
'A' => {
let (cx, cy, r, a0, a1) = (a[0], a[1], a[2], a[3], a[4]);
if r <= 0.0 || (a1 - a0).abs() > 720.0 {
return Err("bad arc".into());
}
let n = ((a1 - a0).abs() / 7.5).ceil().max(2.0) as usize;
for k in 0..=n {
let t = (a0 + (a1 - a0) * k as f32 / n as f32).to_radians();
cur.push([cx + r * t.cos(), cy + r * t.sin()]);
}
}
'O' => {
flush(&mut cur, &mut subs, false);
let (cx, cy, r) = (a[0], a[1], a[2]);
if r <= 0.0 {
return Err("bad circle".into());
}
let pts = (0..48).map(|k| (k as f32 * 7.5).to_radians()).map(|t| [cx + r * t.cos(), cy + r * t.sin()]).collect();
subs.push((pts, true));
}
'R' => {
flush(&mut cur, &mut subs, false);
let (x, y, w, h) = (a[0], a[1], a[2], a[3]);
if w <= 0.0 || h <= 0.0 {
return Err("bad rectangle".into());
}
let r = a[4].clamp(0.0, w.min(h) / 2.0);
let mut pts = Vec::new();
for (cx, cy, start) in [(x + w - r, y + r, -90.0f32), (x + w - r, y + h - r, 0.0), (x + r, y + h - r, 90.0), (x + r, y + r, 180.0)] {
for k in 0..=6 {
let t = (start + 15.0 * k as f32).to_radians();
pts.push([cx + r * t.cos(), cy + r * t.sin()]);
}
}
pts.dedup();
subs.push((pts, true));
}
_ => flush(&mut cur, &mut subs, true),
}
last = Some(cmd);
}
flush(&mut cur, &mut subs, false);
Ok(subs)
}
/// Rasterise `icon` into a transparent `px`×`px` image in `ink` (premultiplied RGBA).
pub fn render(icon: &Icon, px: u32, ink: Rgb) -> Rendered {
let side = px.clamp(8, 4096) as u16;
let mut ctx = RenderContext::new_with(side, side, vello_cpu::RenderSettings { num_threads: crate::default_threads(), ..Default::default() });
ctx.set_transform(Affine::scale(side as f64 / GRID as f64));
ctx.set_paint(crate::color(ink, 1.0));
ctx.set_stroke(kurbo::Stroke::new(STROKE as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
for piece in icon.pieces() {
let mut path = BezPath::new();
for (k, p) in piece.points.iter().enumerate() {
let p = (p[0] as f64, p[1] as f64);
if k == 0 { path.move_to(p) } else { path.line_to(p) }
}
if piece.closed {
path.close_path();
}
if piece.fill {
ctx.fill_path(&path);
} else {
ctx.stroke_path(&path);
}
}
ctx.flush();
let mut pixels = vec![0u8; side as usize * side as usize * 4];
let mut res = Resources::new();
if let Some(pm) = vello_cpu::PixmapMut::new(side, side, &mut pixels) {
ctx.render(pm, &mut res);
}
Rendered { width: side as u32, height: side as u32, pixels }
}
#[cfg(test)]
mod tests {
use super::*;
/// Twice the signed area's sign of each corner; a convex outline never changes turning sign.
fn convex(points: &[[f32; 2]]) -> bool {
let n = points.len();
let mut sign = 0.0f32;
for k in 0..n {
let (a, b, c) = (points[k], points[(k + 1) % n], points[(k + 2) % n]);
let cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
if cross.abs() < 1e-4 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return false;
}
}
true
}
#[test]
fn every_icon_is_unique_parses_fits_the_grid_and_renders() {
assert!((40..=80).contains(&ICONS.len()), "{} icons", ICONS.len());
let mut ids = std::collections::HashSet::new();
for icon in ICONS {
assert!(ids.insert(icon.id), "duplicate id {}", icon.id);
assert!(!icon.name.is_empty() && !icon.keywords.is_empty(), "{}", icon.id);
let pieces = icon.parse().unwrap_or_else(|e| panic!("{e}"));
assert!(!pieces.is_empty(), "{} draws nothing", icon.id);
for p in &pieces {
assert!(p.points.len() >= 2, "{}: degenerate subpath", icon.id);
// Strokes keep half the pen inside the grid.
let m = if p.fill { 0.0 } else { STROKE / 2.0 - 0.01 };
for &[x, y] in &p.points {
assert!((m..=GRID - m).contains(&x) && (m..=GRID - m).contains(&y), "{}: ({x}, {y}) leaves the grid", icon.id);
}
if p.fill {
assert!(convex(&p.points), "{}: filled outlines must be convex", icon.id);
}
}
let img = render(icon, 48, Rgb::BLACK);
let inked = img.pixels.as_chunks::<4>().0.iter().filter(|px| px[3] > 0).count();
assert!(inked > 48, "{} renders blank", icon.id);
// Nothing touches the image's edge (margins are part of the design).
let edge = |x: usize, y: usize| img.pixels.get((y * 48 + x) * 4 + 3).copied().unwrap_or(0);
assert!((0..48).all(|k| edge(k, 0) == 0 && edge(k, 47) == 0 && edge(0, k) == 0 && edge(47, k) == 0), "{} is clipped", icon.id);
}
assert!(Category::ALL.iter().all(|c| ICONS.iter().any(|i| i.category == *c)), "every category has icons");
}
#[test]
fn search_matches_words_names_keywords_and_categories() {
let ids = |q: &str, c: Option<Category>| search(q, c).iter().map(|i| i.id).collect::<Vec<_>>();
assert_eq!(search("", None).len(), ICONS.len());
assert_eq!(ids("Envelope", None), ["mail"]);
assert!(ids("weather", None).contains(&"rain"));
assert_eq!(ids("arrow up", None), ["arrow-up"]);
assert!(ids("help", Some(Category::Symbols)).contains(&"question"));
assert!(ids("help", Some(Category::Arrows)).is_empty());
assert!(ids("zzz-nothing", None).is_empty());
assert_eq!(Category::parse(" weather "), Some(Category::Weather));
assert!(find("heart").is_some() && find("nope").is_none());
assert!(flatten("M1 1 L").is_err() && flatten("X 1").is_err() && flatten("L 2 2").is_err() && flatten("5").is_err());
}
}

View File

@ -15,6 +15,8 @@ use wordcraft_layout::Page;
use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display}; use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display};
use wordcraft_metafile::{Item, Picture, PlacedItem, Seg}; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg};
pub mod icon_lib;
/// Worker threads for rasterising (0 on the web, where there are no threads). /// Worker threads for rasterising (0 on the web, where there are no threads).
pub fn default_threads() -> u16 { pub fn default_threads() -> u16 {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]

View File

@ -1,6 +1,6 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark, //! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table //! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table
//! Properties, Enclose Characters, Command search, Paste Special, About, Save Changes, the password to open a //! Properties, Enclose Characters, Command search, Icons, Paste Special, About, Save Changes, the password to open a
//! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find //! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find
//! Recipient, merge rules, Match Fields and Check for Errors (Customize Keyboard is in //! Recipient, merge rules, Match Fields and Check for Errors (Customize Keyboard is in
//! `dialogs_keyboard`, Customize Ribbon in `dialogs_ribbon`). Every dialog ends by running a //! `dialogs_keyboard`, Customize Ribbon in `dialogs_ribbon`). Every dialog ends by running a
@ -164,6 +164,12 @@ pub enum Dialog {
}, },
/// Columns, Symbol and Field ([`crate::dialogs_insert`], #321). /// Columns, Symbol and Field ([`crate::dialogs_insert`], #321).
Insert(Box<crate::dialogs_insert::InsertDialog>), Insert(Box<crate::dialogs_insert::InsertDialog>),
/// Insert › Icons: the icon library, searchable and filtered by category (empty for all).
Icons {
query: String,
category: String,
selected: String,
},
/// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out /// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out
/// of the serialized dialog state (it can be megabytes). /// of the serialized dialog state (it can be megabytes).
PasteSpecial { PasteSpecial {
@ -672,6 +678,7 @@ impl Dialog {
Dialog::TableStyle { .. } => "modifyTableStyle", Dialog::TableStyle { .. } => "modifyTableStyle",
Dialog::Commands { .. } => "commands", Dialog::Commands { .. } => "commands",
Dialog::Insert(d) => d.name(), Dialog::Insert(d) => d.name(),
Dialog::Icons { .. } => "icons",
Dialog::PasteSpecial { .. } => "pasteSpecial", Dialog::PasteSpecial { .. } => "pasteSpecial",
Dialog::About { .. } => "about", Dialog::About { .. } => "about",
Dialog::SaveChanges { .. } => "saveChanges", Dialog::SaveChanges { .. } => "saveChanges",
@ -825,6 +832,7 @@ impl Dialog {
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
} }
"commands" => Dialog::Commands { query: String::new() }, "commands" => Dialog::Commands { query: String::new() },
"icons" => Dialog::Icons { query: String::new(), category: String::new(), selected: String::new() },
"tableProperties" => { "tableProperties" => {
let form = Box::new(TableForm::read(app)?); let form = Box::new(TableForm::read(app)?);
Dialog::TableProperties { basis: form.clone(), form } Dialog::TableProperties { basis: form.clone(), form }
@ -1030,6 +1038,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::TableStyle { .. } => "Modify Table Style", Dialog::TableStyle { .. } => "Modify Table Style",
Dialog::Commands { .. } => "Search Commands", Dialog::Commands { .. } => "Search Commands",
Dialog::Insert(d) => d.title(), Dialog::Insert(d) => d.title(),
Dialog::Icons { .. } => "Icons",
Dialog::PasteSpecial { .. } => "Paste Special", Dialog::PasteSpecial { .. } => "Paste Special",
Dialog::About { .. } => "About WordCraft", Dialog::About { .. } => "About WordCraft",
Dialog::SaveChanges { .. } => "WordCraft", Dialog::SaveChanges { .. } => "WordCraft",
@ -1763,6 +1772,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
} }
close || ui.input(|i| i.key_pressed(egui::Key::Escape)) close || ui.input(|i| i.key_pressed(egui::Key::Escape))
} }
Dialog::Icons { query, category, selected } => icons_body(app, ui, query, category, selected),
Dialog::PasteSpecial { formats, choice, payload } => { Dialog::PasteSpecial { formats, choice, payload } => {
ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5))); ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5)));
if formats.is_empty() { if formats.is_empty() {
@ -2439,6 +2449,69 @@ fn describe(st: &Value) -> String {
parts.join(", ") parts.join(", ")
} }
/// path data; Insert (or a double click) runs `insert.icon`. Returns true to close.
fn icons_body(app: &mut WordApp, ui: &mut Ui, query: &mut String, category: &mut String, selected: &mut String) -> bool {
use wordcraft_render::icon_lib::{self, Category};
let t = Tokens::get(ui.ctx());
ui.set_width(560.0);
// Typing goes to the search box (never to the document behind the dialog).
ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Search icons")).desired_width(560.0)).request_focus();
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
if ui.selectable_label(category.is_empty(), tl!("All")).clicked() {
category.clear();
}
for c in Category::ALL {
if ui.selectable_label(*category == c.name(), tl!(c.name())).clicked() {
*category = c.name().to_string();
}
}
});
ui.add_space(4.0);
let hits = icon_lib::search(query, Category::parse(category));
let mut insert = false;
egui::ScrollArea::vertical().max_height(300.0).min_scrolled_height(300.0).auto_shrink([true, false]).show(ui, |ui| {
if hits.is_empty() {
ui.label(egui::RichText::new(tl!("No icons match your search.")).color(t.text_dim));
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
for icon in &hits {
let (rect, resp) = ui.allocate_exact_size(vec2(56.0, 56.0), Sense::click());
let on = *selected == icon.id;
let fill = if on {
t.checked
} else if resp.hovered() {
t.hover
} else {
egui::Color32::TRANSPARENT
};
let edge = if on { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.border) };
ui.painter().rect(rect, 4.0, fill, edge, egui::StrokeKind::Inside);
crate::icons::library(ui.painter(), rect.shrink(12.0), icon, t.text);
let resp = resp.on_hover_text(icon.name);
if resp.clicked() {
*selected = icon.id.to_string();
}
if resp.double_clicked() {
*selected = icon.id.to_string();
insert = true;
}
}
});
});
let name = icon_lib::find(selected).map(|i| i.name).unwrap_or("");
ui.add_space(4.0);
ui.label(egui::RichText::new(name).color(t.text_dim));
let (ok, cancel) = buttons(ui, tl!("Insert"));
if (ok || insert) && !selected.is_empty() {
let _ = app.run("insert.icon", json!({"id": selected.as_str()}));
return true;
}
cancel
}
/// Enclose Characters' preview: the text in the chosen shape, shrunk or with the shape enlarged /// Enclose Characters' preview: the text in the chosen shape, shrunk or with the shape enlarged
/// (drawn, so it doesn't depend on the interface font having ○ □ △ ◇). /// (drawn, so it doesn't depend on the interface font having ○ □ △ ◇).
fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str) { fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str) {

View File

@ -1410,6 +1410,18 @@
New Group Nuevo grupo New Group Nuevo grupo
Rename… Cambiar nombre… Rename… Cambiar nombre…
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
Arrows Flechas
Communication Comunicación
Office Oficina
People Personas
Nature Naturaleza
Weather Tiempo
Technology Tecnología
Transport Transporte
Icon Color Color del icono
Search icons Buscar iconos
No icons match your search. Ningún icono coincide con la búsqueda.
Recolor a one-color picture, such as an icon Cambiar el color de una imagen de un solo color, como un icono
Enclose Characters Encerrar caracteres Enclose Characters Encerrar caracteres
Shrink text Reducir texto Shrink text Reducir texto
Enlarge symbol Agrandar símbolo Enlarge symbol Agrandar símbolo

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1396,6 +1396,18 @@
Ribbon Lint Ribbon Lint
Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba:
Ribbon tabs Lindi vahekaardid Ribbon tabs Lindi vahekaardid
Arrows Nooled
Communication Suhtlus
Office Kontor
People Inimesed
Nature Loodus
Weather Ilm
Technology Tehnoloogia
Transport Transport
Icon Color Ikooni värv
Search icons Otsi ikoone
No icons match your search. Otsingule vastavaid ikoone pole.
Recolor a one-color picture, such as an icon Muuda ühevärvilise pildi, näiteks ikooni, värvi
Enclose Characters Ümbritsetud märgid Enclose Characters Ümbritsetud märgid
Shrink text Vähenda teksti Shrink text Vähenda teksti
Enlarge symbol Suurenda sümbolit Enlarge symbol Suurenda sümbolit

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1394,6 +1394,18 @@
New Group 新しいグループ New Group 新しいグループ
Rename… 名前の変更… Rename… 名前の変更…
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
Arrows 矢印
Communication コミュニケーション
Office オフィス
People 人物
Nature 自然
Weather 天気
Technology テクノロジー
Transport 乗り物
Icon Color アイコンの色
Search icons アイコンを検索
No icons match your search. 検索に一致するアイコンはありません。
Recolor a one-color picture, such as an icon アイコンなどの単色の画像の色を変更
Enclose Characters 囲い文字 Enclose Characters 囲い文字
Shrink text 文字を縮小 Shrink text 文字を縮小
Enlarge symbol 記号を拡大 Enlarge symbol 記号を拡大

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -1406,6 +1406,18 @@
New Group Novo Grupo New Group Novo Grupo
Rename… Renomear… Rename… Renomear…
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
Arrows Setas
Communication Comunicação
Office Escritório
People Pessoas
Nature Natureza
Weather Clima
Technology Tecnologia
Transport Transporte
Icon Color Cor do Ícone
Search icons Pesquisar ícones
No icons match your search. Nenhum ícone corresponde à pesquisa.
Recolor a one-color picture, such as an icon Mudar a cor de uma imagem de uma só cor, como um ícone
Enclose Characters Envolver caracteres Enclose Characters Envolver caracteres
Shrink text Reduzir texto Shrink text Reduzir texto
Enlarge symbol Ampliar símbolo Enlarge symbol Ampliar símbolo

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1790,3 +1790,14 @@ preview Title Пример
Left Only Только слева Left Only Только слева
Right Only Только справа Right Only Только справа
Largest Only Только по большей стороне Largest Only Только по большей стороне
Communication Связь
Office Офис
People Люди
Nature Природа
Weather Погода
Technology Технологии
Transport Транспорт
Icon Color Цвет значка
Search icons Поиск значков
No icons match your search. Нет значков, соответствующих поиску.
Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1404,6 +1404,18 @@
New Group Nova grupa New Group Nova grupa
Rename… Preimenuj… Rename… Preimenuj…
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
Arrows Strelice
Communication Komunikacija
Office Kancelarija
People Ljudi
Nature Priroda
Weather Vreme
Technology Tehnologija
Transport Prevoz
Icon Color Boja ikonice
Search icons Pretraži ikonice
No icons match your search. Nijedna ikonica ne odgovara pretrazi.
Recolor a one-color picture, such as an icon Promeni boju jednobojne slike, kao što je ikonica
Enclose Characters Uokviri znakove Enclose Characters Uokviri znakove
Shrink text Smanji tekst Shrink text Smanji tekst
Enlarge symbol Uvećaj simbol Enlarge symbol Uvećaj simbol

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1408,6 +1408,18 @@
New Group Нова група New Group Нова група
Rename… Преименуј… Rename… Преименуј…
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
Arrows Стрелице
Communication Комуникација
Office Канцеларија
People Људи
Nature Природа
Weather Време
Technology Технологија
Transport Превоз
Icon Color Боја иконице
Search icons Претражи иконице
No icons match your search. Ниједна иконица не одговара претрази.
Recolor a one-color picture, such as an icon Промени боју једнобојне слике, као што је иконица
Enclose Characters Уоквири знакове Enclose Characters Уоквири знакове
Shrink text Смањи текст Shrink text Смањи текст
Enlarge symbol Увећај симбол Enlarge symbol Увећај симбол

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -1408,3 +1408,15 @@
Left Only Yalnızca Sol Left Only Yalnızca Sol
Right Only Yalnızca Sağ Right Only Yalnızca Sağ
Largest Only Yalnızca En Geniş Largest Only Yalnızca En Geniş
Arrows Oklar
Communication İletişim
Office Ofis
People İnsanlar
Nature Doğa
Weather Hava Durumu
Technology Teknoloji
Transport Ulaşım
Icon Color Simge Rengi
Search icons Simgelerde ara
No icons match your search. Aramanızla eşleşen simge yok.
Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1406,6 +1406,18 @@
New Group Нова група New Group Нова група
Rename… Перейменувати… Rename… Перейменувати…
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
Arrows Стрілки
Communication Спілкування
Office Офіс
People Люди
Nature Природа
Weather Погода
Technology Технології
Transport Транспорт
Icon Color Колір піктограми
Search icons Пошук піктограм
No icons match your search. Немає піктограм, що відповідають пошуку.
Recolor a one-color picture, such as an icon Змінити колір одноколірного зображення, наприклад піктограми
Enclose Characters Обведення символів Enclose Characters Обведення символів
Shrink text Зменшити текст Shrink text Зменшити текст
Enlarge symbol Збільшити символ Enlarge symbol Збільшити символ

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1406,6 +1406,18 @@
New Group 新建组 New Group 新建组
Rename… 重命名… Rename… 重命名…
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
Arrows 箭头
Communication 通信
Office 办公
People 人物
Nature 自然
Weather 天气
Technology 科技
Transport 交通
Icon Color 图标颜色
Search icons 搜索图标
No icons match your search. 没有与搜索匹配的图标。
Recolor a one-color picture, such as an icon 为图标等单色图片重新着色
Enclose Characters 带圈字符 Enclose Characters 带圈字符
Shrink text 缩小文字 Shrink text 缩小文字
Enlarge symbol 增大圈号 Enlarge symbol 增大圈号

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1395,6 +1395,18 @@
New Group 新增群組 New Group 新增群組
Rename… 重新命名… Rename… 重新命名…
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
Arrows 箭頭
Communication 通訊
Office 辦公
People 人物
Nature 自然
Weather 天氣
Technology 科技
Transport 交通
Icon Color 圖示色彩
Search icons 搜尋圖示
No icons match your search. 沒有符合搜尋的圖示。
Recolor a one-color picture, such as an icon 為圖示等單色圖片重新著色
Enclose Characters 圍繞字元 Enclose Characters 圍繞字元
Shrink text 縮小文字 Shrink text 縮小文字
Enlarge symbol 放大符號 Enlarge symbol 放大符號

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -1172,3 +1172,26 @@ impl ThenLines for () {
pen.lines(x0, x1, ys); pen.lines(x0, x1, ys);
} }
} }
/// Draw an Insert › Icons library icon into `r` in `c`, from the same path data the inserted
/// picture is rendered from (`wordcraft_render::icon_lib`).
pub fn library(p: &Painter, r: Rect, icon: &wordcraft_render::icon_lib::Icon, c: Color32) {
use wordcraft_render::icon_lib::{GRID, STROKE};
let s = r.width().min(r.height()) / GRID;
let o = r.center() - vec2(GRID / 2.0 * s, GRID / 2.0 * s);
let stroke = Stroke::new(STROKE * s, c);
for piece in icon.pieces() {
let pts: Vec<Pos2> = piece.points.iter().map(|q| pos2(o.x + q[0] * s, o.y + q[1] * s)).collect();
if piece.fill {
p.add(Shape::convex_polygon(pts, c, Stroke::NONE));
} else if piece.closed {
p.add(Shape::closed_line(pts, stroke));
} else {
// Round caps, as on the inserted picture.
for end in [pts.first(), pts.last()].into_iter().flatten() {
p.circle_filled(*end, stroke.width / 2.0, c);
}
p.add(Shape::line(pts, stroke));
}
}
}

View File

@ -1243,6 +1243,19 @@ mod tests {
WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default())
} }
/// Insert › Icons (#309) opens the icon picker from the ribbon; an id inserts without it.
#[test]
fn icons_button_opens_the_picker_and_ids_insert_directly() {
let mut a = app();
a.run("insert.icon", json!({})).unwrap();
assert_eq!(a.dialog.as_ref().map(dialogs::Dialog::name), Some("icons"));
a.dialog = None;
let r = a.run("insert.icon", json!({"id": "sun", "size": 36})).unwrap();
assert_eq!(r["icon"], "sun");
assert!(a.dialog.is_none(), "an id inserts without the dialog");
assert!(a.selected_picture_media().is_some());
}
/// Table Layout › Properties (#44) opens the dialog from the ribbon; OK sends only what /// Table Layout › Properties (#44) opens the dialog from the ribbon; OK sends only what
/// changed, as one `table.properties` step. Scripts with settings never see the dialog. /// changed, as one `table.properties` step. Scripts with settings never see the dialog.
#[test] #[test]

View File

@ -1425,6 +1425,13 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
mi(ui, app, "Sepia", "picture.color", json!({"mode": "sepia"})); mi(ui, app, "Sepia", "picture.color", json!({"mode": "sepia"}));
mi(ui, app, "Washout", "picture.color", json!({"mode": "washout"})); mi(ui, app, "Washout", "picture.color", json!({"mode": "washout"}));
}); });
menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, |ui, app| {
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("picture.iconColor", json!({"color": hex}));
ui.close();
}
});
menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, |ui, app| { menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, |ui, app| {
for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] { for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] {
mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v})); mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v}));

View File

@ -20,7 +20,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | | 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, Drawing Canvas | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) |
| 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) | | 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) |
@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap |
| 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain | | 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain |
| 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain | | 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain |
| 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) | | 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) |

View File

@ -2,14 +2,14 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**420 of 448 catalog features have commands (93.8%).** **421 of 448 catalog features have commands (94.0%).**
| Tab | Live | Total | | Tab | Live | Total |
|---|---|---| |---|---|---|
| File | 20 | 20 | | File | 20 | 20 |
| Quick Access | 4 | 4 | | Quick Access | 4 | 4 |
| Home | 101 | 102 | | Home | 101 | 102 |
| Insert | 43 | 53 | | Insert | 44 | 53 |
| Draw | 5 | 11 | | Draw | 5 | 11 |
| Design | 10 | 10 | | Design | 10 | 10 |
| Layout | 19 | 19 | | Layout | 19 | 19 |
@ -35,7 +35,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
### Insert ### Insert
- Illustrations › Online Pictures (`insert.onlinePicture`) - Illustrations › Online Pictures (`insert.onlinePicture`)
- Illustrations › Icons (`insert.icon`)
- Illustrations › 3D Models (`insert.model3d`) - Illustrations › 3D Models (`insert.model3d`)
- Illustrations › SmartArt (`insert.smartArt`) - Illustrations › SmartArt (`insert.smartArt`)
- Illustrations › Chart (`insert.chart`) - Illustrations › Chart (`insert.chart`)

View File

@ -47,7 +47,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
| M0 | Skeleton and vertical slice (model, layout, render, engine, Word-style UI, CLI, MCP, web) | done | — | | M0 | Skeleton and vertical slice (model, layout, render, engine, Word-style UI, CLI, MCP, web) | done | — |
| M1 | DOCX I/O | done (first version); real-world corpus not started; charts/SmartArt/OLE dropped | 45–75 h | | M1 | DOCX I/O | done (first version); real-world corpus not started; charts/SmartArt/OLE dropped | 45–75 h |
| M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four) | 5–10 h | | M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four) | 5–10 h |
| M3 | Insert tab | mostly done; equations done (#191); charts, SmartArt, icons, 3D, screenshot, online media missing | 50–80 h | | M3 | Insert tab | mostly done; equations done (#191), icons done (#309); charts, SmartArt, 3D, screenshot, online media missing | 50–80 h |
| M4 | Layout and Design tabs | done except column balancing, group | 6–10 h | | M4 | Layout and Design tabs | done except column balancing, group | 6–10 h |
| M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h | | M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h |
| M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation missing; all 12 bibliography styles (#383) | 15–25 h | | M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation missing; all 12 bibliography styles (#383) | 15–25 h |
@ -71,6 +71,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | trivial | M3: icons landed (#309) |
| 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) | | 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) |
| 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) | | 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) |
| 2026-10-11 | trivial | M7: formatting revisions landed (#41) | | 2026-10-11 | trivial | M7: formatting revisions landed (#41) |

View File

@ -68,9 +68,9 @@ full parity for the area.
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, Icons, online pictures, screenshot. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, online pictures, screenshot. |
| Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. |
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | | Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). |
| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. | | View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. |
| Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. |
| Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. |
@ -249,6 +249,7 @@ can be used without committing Word output.
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | trivial | Insert › Icons (#309): catalog 402/434 → 403/434 (92.9%); moved from missing to done under pictures and shapes |
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 402/434 → 403/434 (92.9%) |
| 2026-10-11 | trivial | Phonetic Guide (ruby) landed (#288): catalog +1 feature | | 2026-10-11 | trivial | Phonetic Guide (ruby) landed (#288): catalog +1 feature |
| 2026-10-11 | trivial | Shortcut count 101 → 125 (#330) | | 2026-10-11 | trivial | Shortcut count 101 → 125 (#330) |

View File

@ -16,7 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 |
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
| Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
| Modal dialogs | **37** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 | | Modal dialogs | **38** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 |
@ -52,6 +52,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 2026-10-10 | trivial | Shortcuts: Alt+Shift+Up/Down move paragraphs, Alt+Shift+Left/Right promote/demote (⌃⇧ arrows on macOS), Shift+F5 Go Back, Alt+X Toggle Unicode (#343) | | 2026-10-10 | trivial | Shortcuts: Alt+Shift+Up/Down move paragraphs, Alt+Shift+Left/Right promote/demote (⌃⇧ arrows on macOS), Shift+F5 Go Back, Alt+X Toggle Unicode (#343) |
| 2026-10-10 | trivial | Rotation handle on pictures, shapes and text boxes; Rotate menu (90° and flips) and a Rotation box in Picture/Shape Format › Size (#332) | | 2026-10-10 | trivial | Rotation handle on pictures, shapes and text boxes; Rotate menu (90° and flips) and a Rotation box in Picture/Shape Format › Size (#332) |
| 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) | | 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) |
| 2026-10-11 | trivial | Insert › Icons picker: search, category chips, icons drawn live from the library's path data; Picture Format › Icon Color (#309) |
| 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) | | 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) |
| 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) | | 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) |
| 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed | | 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed |