mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 03:47:09 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
a5b17eb3a4
commit
8a2a8bb60d
@@ -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.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.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)
|
||||
.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)
|
||||
@@ -206,6 +208,12 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.ui_requests.push(json!({"open": "insertPicture"}));
|
||||
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 {
|
||||
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.
|
||||
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);
|
||||
if let Some(want) = p::f32(v, "width") {
|
||||
if let Some(want) = width {
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
w *= k;
|
||||
h *= k;
|
||||
@@ -234,20 +242,44 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let obj = InlineObject::Image {
|
||||
media: key.clone(),
|
||||
w,
|
||||
h,
|
||||
alt: p::str(v, "alt").unwrap_or("").to_string(),
|
||||
float: Float::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
};
|
||||
let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
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 {
|
||||
let kind: ShapeKind =
|
||||
serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
|
||||
@@ -106,6 +106,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
|
||||
.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| {
|
||||
let e = p::str(v, "effect").unwrap_or("blur").to_string();
|
||||
adjust(s, move |img| match e.as_str() {
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
#[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
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user