mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 12:57:09 -06:00
600 lines
27 KiB
Rust
600 lines
27 KiB
Rust
//! Picture Format, Shape Format and Arrange: commands on the selected inline object.
|
||
//!
|
||
//! The selected object is the first picture/shape inside the selection (or right before the
|
||
//! caret). Picture adjustments re-encode the bitmap (originals are kept for Reset Picture).
|
||
|
||
use serde_json::{Value, json};
|
||
use wordcraft_doc::para::{Float, InlineObject, Wrap};
|
||
use wordcraft_doc::props::Rgb;
|
||
use wordcraft_doc::{Pos, StoryRef};
|
||
|
||
use super::sel_result;
|
||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||
|
||
fn has_picture(s: &Session) -> Option<&'static str> {
|
||
match selected(s) {
|
||
Some((_, InlineObject::Image { .. })) => None,
|
||
_ => Some("select a picture first"),
|
||
}
|
||
}
|
||
fn has_object(s: &Session) -> Option<&'static str> {
|
||
if selected(s).is_some() { None } else { Some("select a picture or shape first") }
|
||
}
|
||
fn has_shape(s: &Session) -> Option<&'static str> {
|
||
match selected(s) {
|
||
Some((_, InlineObject::Shape { .. })) => None,
|
||
_ => Some("select a shape first"),
|
||
}
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size).params(r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent}"#).when(has_object),
|
||
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", |s, v| {
|
||
let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45));
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { crop, .. } = o {
|
||
*crop = c;
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"left"?, "top"?, "right"?, "bottom"? (fractions 0–0.45)}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.altText", "Alt Text", "Picture Format › Accessibility", |s, v| {
|
||
let t = p::req_str(v, "text")?.to_string();
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { alt, .. } = o {
|
||
*alt = t.clone();
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"text": string}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.corrections", "Corrections", "Picture Format › Adjust", |s, v| {
|
||
let b = p::f32(v, "brightness").unwrap_or(0.0).clamp(-100.0, 100.0);
|
||
let c = p::f32(v, "contrast").unwrap_or(0.0).clamp(-100.0, 100.0);
|
||
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
|
||
adjust(s, move |img| {
|
||
let mut img = img;
|
||
if b != 0.0 {
|
||
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
|
||
}
|
||
if c != 0.0 {
|
||
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::contrast(&img, c));
|
||
}
|
||
if sharpen > 0.0 {
|
||
img = img.unsharpen(1.0 + sharpen / 50.0, 2);
|
||
} else if sharpen < 0.0 {
|
||
img = img.blur(-sharpen / 25.0);
|
||
}
|
||
img
|
||
})
|
||
})
|
||
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.color", "Color", "Picture Format › Adjust", |s, v| {
|
||
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
|
||
let sat = p::f32(v, "saturation").unwrap_or(100.0);
|
||
adjust(s, move |img| recolor(img, &mode, sat))
|
||
})
|
||
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
|
||
.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() {
|
||
"blur" => img.blur(3.0),
|
||
"sharpen" => img.unsharpen(2.0, 2),
|
||
"invert" => {
|
||
let mut i = img;
|
||
i.invert();
|
||
i
|
||
}
|
||
"posterize" => {
|
||
let mut rgba = img.to_rgba8();
|
||
for px in rgba.pixels_mut() {
|
||
for c in px.0.iter_mut().take(3) {
|
||
*c = (*c / 64) * 85;
|
||
}
|
||
}
|
||
image::DynamicImage::ImageRgba8(rgba)
|
||
}
|
||
"pixelate" => {
|
||
let (w, h) = (img.width().max(1), img.height().max(1));
|
||
img.resize_exact((w / 12).max(1), (h / 12).max(1), image::imageops::FilterType::Nearest).resize_exact(w, h, image::imageops::FilterType::Nearest)
|
||
}
|
||
_ => img,
|
||
})
|
||
})
|
||
.params(r#"{"effect": "blur|sharpen|invert|posterize|pixelate"}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.transparency", "Transparency", "Picture Format › Adjust", |s, v| {
|
||
let t = p::f32(v, "percent").unwrap_or(50.0).clamp(0.0, 100.0);
|
||
adjust(s, move |img| {
|
||
let mut rgba = img.to_rgba8();
|
||
for px in rgba.pixels_mut() {
|
||
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
|
||
}
|
||
image::DynamicImage::ImageRgba8(rgba)
|
||
})
|
||
})
|
||
.params(r#"{"percent": 0..100}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
|
||
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
|
||
adjust(s, move |img| remove_background(img, tol))
|
||
})
|
||
.params(r#"{"tolerance"?: 0..255}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
|
||
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
|
||
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
|
||
})
|
||
.params(r#"{"maxPixels"?: n}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.reset", "Reset Picture", "Picture Format › Adjust", reset).when(has_picture),
|
||
CommandSpec::new("picture.change", "Change Picture", "Picture Format › Adjust", |s, v| {
|
||
let bytes = if let Some(d) = p::str(v, "data") {
|
||
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?
|
||
} else if let Some(path) = p::str(v, "path") {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let _ = path;
|
||
return Err(CmdError::Failed("paths aren't available on the web".into()));
|
||
}
|
||
} else {
|
||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||
};
|
||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
|
||
let key = s.doc.add_media(bytes, "png");
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { media, .. } = o {
|
||
*media = key.clone();
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"path"?: string, "data"?: base64}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.style", "Picture Styles", "Picture Format › Picture Styles", |s, v| {
|
||
let style = p::str(v, "style").unwrap_or("simpleFrame").to_string();
|
||
adjust(s, move |img| picture_style(img, &style))
|
||
})
|
||
.params(r#"{"style": "simpleFrame|thickFrame|rounded|softEdge|shadow"}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("picture.border", "Picture Border", "Picture Format › Picture Styles", |s, v| {
|
||
let c = p::str(v, "color").and_then(Rgb::parse).unwrap_or(Rgb::BLACK);
|
||
let w = p::u64(v, "width").unwrap_or(4).clamp(1, 200) as u32;
|
||
adjust(s, move |img| frame(img, w, [c.0, c.1, c.2, 255]))
|
||
})
|
||
.params(r#"{"color"?: "RRGGBB", "width"?: px}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("arrange.rotate", "Rotate", "Layout › Arrange", |s, v| {
|
||
let how = p::str(v, "direction").unwrap_or("right").to_string();
|
||
let swap = how == "right" || how == "left";
|
||
let r = adjust(s, move |img| match how.as_str() {
|
||
"left" => img.rotate270(),
|
||
"flipH" => img.fliph(),
|
||
"flipV" => img.flipv(),
|
||
_ => img.rotate90(),
|
||
})?;
|
||
if swap {
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { w, h, .. } = o {
|
||
std::mem::swap(w, h);
|
||
}
|
||
})?;
|
||
}
|
||
Ok(r)
|
||
})
|
||
.params(r#"{"direction": "right|left|flipH|flipV"}"#)
|
||
.when(has_picture),
|
||
CommandSpec::new("arrange.wrap", "Wrap Text", "Layout › Arrange", |s, v| {
|
||
let wrap: Wrap = serde_json::from_value(v.get("wrap").cloned().unwrap_or(json!("square"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||
with_float(s, |f| f.wrap = wrap)
|
||
})
|
||
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
|
||
.when(has_object),
|
||
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
|
||
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
|
||
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
|
||
let sect = super::page::sect(s);
|
||
let size = selected(s).map(|(_, o)| obj_size(&o)).unwrap_or((72.0, 72.0));
|
||
with_float(s, |f| {
|
||
if f.wrap == Wrap::Inline {
|
||
f.wrap = Wrap::Square;
|
||
}
|
||
f.h_rel = wordcraft_doc::para::Anchor::Margin;
|
||
f.v_rel = wordcraft_doc::para::Anchor::Margin;
|
||
let (tw, th) = (sect.text_width(), sect.text_height());
|
||
let col = if preset.ends_with("Left") { 0.0 } else if preset.ends_with("Right") { tw - size.0 } else { (tw - size.0) / 2.0 };
|
||
let row = if preset.starts_with("top") { 0.0 } else if preset.starts_with("bottom") { th - size.1 } else { (th - size.1) / 2.0 };
|
||
f.x = x.unwrap_or(col);
|
||
f.y = y.unwrap_or(row);
|
||
})
|
||
})
|
||
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
|
||
.when(has_object),
|
||
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_object),
|
||
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_object),
|
||
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
|
||
let h = p::str(v, "value").unwrap_or("center").to_string();
|
||
let tw = super::page::sect(s).text_width();
|
||
let w = selected(s).map(|(_, o)| obj_size(&o).0).unwrap_or(72.0);
|
||
with_float(s, |f| {
|
||
if f.wrap == Wrap::Inline {
|
||
f.wrap = Wrap::Square;
|
||
}
|
||
f.h_rel = wordcraft_doc::para::Anchor::Margin;
|
||
f.x = match h.as_str() {
|
||
"left" => 0.0,
|
||
"right" => tw - w,
|
||
_ => (tw - w) / 2.0,
|
||
};
|
||
})
|
||
})
|
||
.params(r#"{"value": "left|center|right"}"#)
|
||
.when(has_object),
|
||
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
|
||
CommandSpec::new("select.objects", "Select Objects", "Home › Editing › Select", |s, v| {
|
||
let n = p::u64(v, "index").unwrap_or(0) as usize;
|
||
let list = all_objects(s);
|
||
let (pos, _) = list.into_iter().nth(n).ok_or_else(|| CmdError::Failed("no such object".into()))?;
|
||
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
|
||
s.sel = Selection { anchor: pos, focus: end };
|
||
sel_result(s)
|
||
})
|
||
.params(r#"{"index"?: n}"#)
|
||
.pure(),
|
||
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", |s, v| {
|
||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Shape { fill, .. } = o {
|
||
*fill = c;
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"color": "RRGGBB" | null}"#)
|
||
.when(has_shape),
|
||
CommandSpec::new("shape.outline", "Shape Outline", "Shape Format › Shape Styles", |s, v| {
|
||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||
let w = p::f32(v, "width");
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Shape { stroke, stroke_width, .. } = o {
|
||
*stroke = c;
|
||
if let Some(w) = w {
|
||
*stroke_width = w.clamp(0.0, 72.0);
|
||
}
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
|
||
.when(has_shape),
|
||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Shape { kind: k, .. } = o {
|
||
*k = kind;
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"kind": string}"#)
|
||
.when(has_shape),
|
||
]
|
||
}
|
||
|
||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||
let (a, b) = s.sel.ordered();
|
||
let story = a.story;
|
||
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
|
||
for path in paths {
|
||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||
for off in p.object_offsets() {
|
||
let inside = if a == b {
|
||
off + wordcraft_doc::para::OBJ.len_utf8() == a.off || off == a.off
|
||
} else {
|
||
(path != a.path || off >= a.off) && (path != b.path || off < b.off)
|
||
};
|
||
if !inside {
|
||
continue;
|
||
}
|
||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||
return Some((Pos { story, path: path.clone(), off }, o.clone()));
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn obj_size(o: &InlineObject) -> (f32, f32) {
|
||
match o {
|
||
InlineObject::Image { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h),
|
||
_ => (0.0, 0.0),
|
||
}
|
||
}
|
||
|
||
fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
|
||
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||
f(o);
|
||
para.touch();
|
||
Ok(json!({"object": serde_json::to_value(para.object_at(pos.off)).unwrap_or(Value::Null)}))
|
||
}
|
||
|
||
fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
||
with_obj(s, |o| match o {
|
||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => f(float),
|
||
_ => {}
|
||
})
|
||
}
|
||
|
||
fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
|
||
let (w0, h0) = obj_size(&o);
|
||
let lock = p::bool(v, "lockAspect").unwrap_or(true);
|
||
let (mut w, mut h) = (w0, h0);
|
||
if let Some(sc) = p::f32(v, "scale") {
|
||
w *= sc / 100.0;
|
||
h *= sc / 100.0;
|
||
}
|
||
match (p::f32(v, "width"), p::f32(v, "height")) {
|
||
(Some(nw), Some(nh)) => {
|
||
w = nw;
|
||
h = nh;
|
||
}
|
||
(Some(nw), None) => {
|
||
if lock && w0 > 0.0 {
|
||
h = h0 * nw / w0;
|
||
}
|
||
w = nw;
|
||
}
|
||
(None, Some(nh)) => {
|
||
if lock && h0 > 0.0 {
|
||
w = w0 * nh / h0;
|
||
}
|
||
h = nh;
|
||
}
|
||
_ => {}
|
||
}
|
||
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
||
with_obj(s, |o| match o {
|
||
InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } => {
|
||
*ow = w;
|
||
*oh = h;
|
||
}
|
||
_ => {}
|
||
})
|
||
}
|
||
|
||
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
|
||
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||
let bytes = s.doc.media.get(&media).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
|
||
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
|
||
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
|
||
return Err(CmdError::Failed("picture is too large to edit".into()));
|
||
}
|
||
let out = f(img);
|
||
let mut png = Vec::new();
|
||
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||
let key = s.doc.add_media(png, "png");
|
||
let orig = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||
s.originals.entry(key.clone()).or_insert(orig);
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { media, .. } = o {
|
||
*media = key.clone();
|
||
}
|
||
})
|
||
}
|
||
|
||
fn reset(s: &mut Session, _: &Value) -> CmdResult {
|
||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||
let orig = s.originals.get(&media).cloned().unwrap_or(media);
|
||
with_obj(s, |o| {
|
||
if let InlineObject::Image { media, crop, .. } = o {
|
||
*media = orig.clone();
|
||
*crop = [0.0; 4];
|
||
}
|
||
})
|
||
}
|
||
|
||
fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImage {
|
||
let mut rgba = img.to_rgba8();
|
||
for px in rgba.pixels_mut() {
|
||
let [r, g, b, a] = px.0;
|
||
let (rf, gf, bf) = (r as f32, g as f32, b as f32);
|
||
let l = 0.299 * rf + 0.587 * gf + 0.114 * bf;
|
||
let out = match mode {
|
||
"grayscale" => [l, l, l],
|
||
"sepia" => [
|
||
(0.393 * rf + 0.769 * gf + 0.189 * bf).min(255.0),
|
||
(0.349 * rf + 0.686 * gf + 0.168 * bf).min(255.0),
|
||
(0.272 * rf + 0.534 * gf + 0.131 * bf).min(255.0),
|
||
],
|
||
"washout" => [rf * 0.35 + 255.0 * 0.65, gf * 0.35 + 255.0 * 0.65, bf * 0.35 + 255.0 * 0.65],
|
||
"blackAndWhite" => {
|
||
let v = if l > 127.0 { 255.0 } else { 0.0 };
|
||
[v, v, v]
|
||
}
|
||
"tint" => [l * 0.6 + 0.4 * 0x3B as f32, l * 0.6 + 0.4 * 0x5B as f32, l * 0.6 + 0.4 * 0xDB as f32],
|
||
_ => {
|
||
let k = sat / 100.0;
|
||
[l + (rf - l) * k, l + (gf - l) * k, l + (bf - l) * k]
|
||
}
|
||
};
|
||
px.0 = [out[0].clamp(0.0, 255.0) as u8, out[1].clamp(0.0, 255.0) as u8, out[2].clamp(0.0, 255.0) as u8, a];
|
||
}
|
||
image::DynamicImage::ImageRgba8(rgba)
|
||
}
|
||
|
||
/// Flood the border-connected region close to the corner colour with transparency.
|
||
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
|
||
let mut rgba = img.to_rgba8();
|
||
let (w, h) = rgba.dimensions();
|
||
if w == 0 || h == 0 {
|
||
return image::DynamicImage::ImageRgba8(rgba);
|
||
}
|
||
let bg = rgba.get_pixel(0, 0).0;
|
||
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
|
||
let mut seen = vec![false; (w as usize) * (h as usize)];
|
||
let mut stack: Vec<(u32, u32)> = Vec::new();
|
||
for x in 0..w {
|
||
stack.push((x, 0));
|
||
stack.push((x, h - 1));
|
||
}
|
||
for y in 0..h {
|
||
stack.push((0, y));
|
||
stack.push((w - 1, y));
|
||
}
|
||
while let Some((x, y)) = stack.pop() {
|
||
let i = (y as usize) * (w as usize) + x as usize;
|
||
if seen.get(i).copied().unwrap_or(true) {
|
||
continue;
|
||
}
|
||
if let Some(sv) = seen.get_mut(i) {
|
||
*sv = true;
|
||
}
|
||
let p = rgba.get_pixel(x, y).0;
|
||
if !close(p) {
|
||
continue;
|
||
}
|
||
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
|
||
if x > 0 {
|
||
stack.push((x - 1, y));
|
||
}
|
||
if x + 1 < w {
|
||
stack.push((x + 1, y));
|
||
}
|
||
if y > 0 {
|
||
stack.push((x, y - 1));
|
||
}
|
||
if y + 1 < h {
|
||
stack.push((x, y + 1));
|
||
}
|
||
}
|
||
image::DynamicImage::ImageRgba8(rgba)
|
||
}
|
||
|
||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
|
||
let (w, h) = (img.width(), img.height());
|
||
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
|
||
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
|
||
image::DynamicImage::ImageRgba8(out)
|
||
}
|
||
|
||
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||
let m = (img.width().max(img.height()) / 40).max(4);
|
||
match style {
|
||
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
|
||
"rounded" | "softEdge" => {
|
||
let mut rgba = img.to_rgba8();
|
||
let (w, h) = rgba.dimensions();
|
||
let r = (w.min(h) as f32) * if style == "rounded" { 0.12 } else { 0.0 };
|
||
let feather = if style == "softEdge" { (w.min(h) as f32 * 0.08).max(2.0) } else { 1.5 };
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||
let dx = (r - fx).max(fx - (w as f32 - r)).max(0.0);
|
||
let dy = (r - fy).max(fy - (h as f32 - r)).max(0.0);
|
||
let edge = (fx.min(w as f32 - fx)).min(fy.min(h as f32 - fy));
|
||
let inside = if r > 0.0 && dx > 0.0 && dy > 0.0 { r - (dx * dx + dy * dy).sqrt() } else { edge };
|
||
let k = (inside / feather).clamp(0.0, 1.0);
|
||
let p = rgba.get_pixel_mut(x, y);
|
||
p.0[3] = (p.0[3] as f32 * k) as u8;
|
||
}
|
||
}
|
||
image::DynamicImage::ImageRgba8(rgba)
|
||
}
|
||
"shadow" => {
|
||
let (w, h) = (img.width(), img.height());
|
||
let off = m;
|
||
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
|
||
let shadow = image::DynamicImage::ImageRgba8(shadow);
|
||
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
|
||
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
|
||
image::imageops::overlay(&mut out, &blurred, 0, 0);
|
||
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
|
||
image::DynamicImage::ImageRgba8(out)
|
||
}
|
||
_ => frame(img, m, [255, 255, 255, 255]),
|
||
}
|
||
}
|
||
|
||
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
|
||
let mut v = Vec::new();
|
||
for path in s.doc.para_paths(StoryRef::Body) {
|
||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||
for off in p.object_offsets() {
|
||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
|
||
}
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
|
||
Ok(Value::Array(
|
||
all_objects(s)
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(i, (pos, o))| {
|
||
let (kind, name) = match &o {
|
||
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
|
||
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
|
||
_ => ("object", format!("Object {}", i + 1)),
|
||
};
|
||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
|
||
})
|
||
.collect(),
|
||
))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||
if x > 2 && x < w - 3 && y > 2 && y < h - 3 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([255, 255, 255, 255]) }
|
||
});
|
||
let mut b = Vec::new();
|
||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png).unwrap();
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn picture_pipeline() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||
assert!(selected(&s).is_some());
|
||
s.run("picture.size", &json!({"width": 100})).unwrap();
|
||
let (_, o) = selected(&s).unwrap();
|
||
assert_eq!(obj_size(&o), (100.0, 50.0));
|
||
let before = s.doc.media.len();
|
||
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
|
||
s.run("picture.removeBackground", &json!({})).unwrap();
|
||
assert!(s.doc.media.len() > before);
|
||
s.run("arrange.rotate", &json!({"direction": "right"})).unwrap();
|
||
assert_eq!(obj_size(&selected(&s).unwrap().1), (50.0, 100.0));
|
||
s.run("picture.reset", &json!({})).unwrap();
|
||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||
s.run("arrange.position", &json!({"preset": "topRight"})).unwrap();
|
||
let l = s.run("arrange.selectionPane", &json!({})).unwrap();
|
||
assert_eq!(l.as_array().unwrap().len(), 1);
|
||
let _ = s.layout();
|
||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
|
||
}
|
||
}
|