//! Picture edit panel commands (plan W10-16, W10-20): size helpers, rotation, //! reread / relink, file information, "Edit Image". // Ported from xfig src/e_edit.c (xfig licence, see LICENSE-xfig). use super::{num, opt_num, string, CmdResult, Command}; use crate::{import, Engine, EngineError}; use rustfig_geom::Point; use rustfig_model::{ObjectId, ObjectKind, Picture}; use serde_json::{json, Value}; use std::sync::Arc as Shared; /// Fig units per pixel of a picture at its original size (80 pixels per inch). pub const UNITS_PER_PIXEL: f64 = 15.0; /// What the panel shows about a picture's file. #[derive(Debug, Clone, PartialEq)] pub struct PictureInfo { /// The file is there. pub exists: bool, /// "PNG", "JPEG", "GIF", "EPS", "PDF", or the file's extension. pub kind: String, pub width: u32, pub height: u32, /// Distinct colours (counting stops at 257). pub colours: usize, /// Some pixel is fully transparent. pub transparent: bool, } /// D11 (`w_capture.c`): the default file name of a screen capture: the figure's name and the /// time in seconds since 1970 (xfig's `figure1_828065129.pcx`), as PNG; `capture_.png` /// while the figure has no name. pub fn capture_file_name(figure: Option<&str>, secs: u64) -> String { let stem = figure .and_then(|f| std::path::Path::new(f).file_stem()) .and_then(|s| s.to_str()) .filter(|s| !s.is_empty()) .unwrap_or("capture"); format!("{stem}_{secs}.png") } /// Type of a picture file: its first bytes, else its extension. pub fn picture_type(path: &str) -> String { use std::io::Read; let mut head = [0u8; 8]; let mut n = std::fs::File::open(path) .and_then(|mut f| f.read(&mut head)) .unwrap_or(0); // A picture kept in memory (the browser, C2). if n == 0 { if let Some(b) = import::stored_picture(path) { n = b.len().min(8); head[..n].copy_from_slice(b.get(..n).unwrap_or_default()); } } let h = head.get(..n).unwrap_or(&[]); if h.starts_with(b"\x89PNG") { "PNG".into() } else if h.starts_with(&[0xFF, 0xD8]) { "JPEG".into() } else if h.starts_with(b"GIF8") { "GIF".into() } else if h.starts_with(b"%PDF") { "PDF".into() } else if h.starts_with(b"%!PS") || h.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) { "EPS".into() } else { std::path::Path::new(path) .extension() .and_then(|e| e.to_str()) .map(str::to_uppercase) .unwrap_or_default() } } /// Information about a picture (read-only). pub fn info(pic: &Picture) -> PictureInfo { // A relative name resolves against the figure's folder, not the working directory `is_file` // looks in: a loaded image then proves the file was found. An absolute name is checked on disk. let path = std::path::Path::new(&pic.file); let exists = path.is_file() || import::stored_picture(&pic.file).is_some() || (pic.image.is_some() && path.is_relative()); let (mut width, mut height, mut colours, mut transparent) = (0, 0, 0, false); if let Some(img) = &pic.image { width = img.width; height = img.height; let mut set = std::collections::HashSet::new(); for px in img.rgba.chunks(4) { let &[r, g, b, a] = px else { continue }; if a == 0 { transparent = true; } else if set.len() <= 256 { set.insert((r, g, b)); } } colours = set.len(); } PictureInfo { exists, kind: picture_type(&pic.file), width, height, colours, transparent, } } /// xfig's rotation of the picture box from the corner order: 0, 90, 180, 270. pub fn rotation_of(p1: Point, p2: Point) -> i32 { let (dx, dy) = (p2.x - p1.x, p2.y - p1.y); if dx < 0.0 && dy < 0.0 { 180 } else if dx < 0.0 { 270 } else if dy < 0.0 { 90 } else { 0 } } /// The opposite corner for rotation `deg`, keeping the corner `p1` and the /// box's width and height. pub fn with_rotation(p1: Point, p2: Point, deg: i32) -> Option { let (w, h) = ((p2.x - p1.x).abs(), (p2.y - p1.y).abs()); let (sx, sy) = match deg { 0 => (1.0, 1.0), 90 => (1.0, -1.0), 180 => (-1.0, -1.0), 270 => (-1.0, 1.0), _ => return None, }; Some(Point::new(p1.x + sx * w, p1.y + sy * h)) } /// How the size buttons change the box. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Resize { /// "Shrink to orig": keep the proportions, shrink the longer side. Shrink, /// "Expand to orig": keep the proportions, grow the shorter side. Expand, /// "Use original size". Original, /// "Scale by %" (of the original size). Percent(f64), } /// New opposite corner after a size button (xfig `shrink_pic`, `expand_pic`, /// `origsize_pic`, `scale_percent_pic`). `width`/`height` are the picture's /// pixels. `None` when the box has no area or the picture no size. pub fn resize_corner(mode: Resize, p1: Point, p2: Point, flipped: bool, width: u32, height: u32) -> Option { if width == 0 || height == 0 { return None; } let (dx, dy) = (p2.x - p1.x, p2.y - p1.y); if dx == 0.0 || dy == 0.0 || !dx.is_finite() || !dy.is_finite() { return None; } let orig_ratio = f64::from(height) / f64::from(width); let (sx, sy) = (dx.signum(), dy.signum()); let rot = rotation_of(p1, p2); let upright = (rot == 0 || rot == 180) != flipped; let size = |pct: f64| { Point::new( p1.x + sx * (f64::from(width) * UNITS_PER_PIXEL * pct / 100.0), p1.y + sy * (f64::from(height) * UNITS_PER_PIXEL * pct / 100.0), ) }; let grow = match mode { Resize::Original => return Some(size(100.0)), Resize::Percent(pct) => { let pct = if pct.is_finite() && pct > 0.0 { pct.min(100_000.0) } else { 0.001 }; return Some(size(pct)); } Resize::Expand => true, Resize::Shrink => false, }; let (ax, ay) = (dx.abs(), dy.abs()); // The box's ratio in the picture's own direction. let ratio = if upright { ay / ax } else { ax / ay }; // Expand changes the side that makes the box larger, shrink the one // that makes it smaller. let change_first = if grow { ratio < orig_ratio } else { ratio > orig_ratio }; let mut q = p2; if upright { if change_first { q.y = p1.y + sy * (ax * orig_ratio).trunc(); } else { q.x = p1.x + sx * (ay / orig_ratio).trunc(); } } else if change_first { q.x = p1.x + sx * (ay * orig_ratio).trunc(); } else { q.y = p1.y + sy * (ax / orig_ratio).trunc(); } Some(q) } pub(super) fn commands() -> Vec { let cmd = |id, label, params, mutates, run| Command { id, label, menu: "", shortcut: "", params, mutates, enabled: super::always, run, }; vec![ cmd( "picture.resize", "Resize picture", "{\"id\", \"mode\": shrink|expand|original|scale, \"percent\"?: number (scale)} — the picture panel's size buttons; moves the opposite corner", true, resize, ), cmd( "picture.rotate", "Rotate picture box", "{\"id\", \"rotation\": 0|90|180|270} — the picture panel's rotation; changes the corner order", true, rotate, ), cmd( "picture.set_file", "Reread / relink picture", "{\"id\", \"path\"?: string (default: the picture's file; Reread)} — loads the file again or points the picture at another one (Relink)", true, set_file, ), cmd( "picture.info", "Picture information", "{\"id\"} -> {file, exists, type, width, height, colours, transparent, rotation, flipped}", false, info_cmd, ), cmd( "picture.store", "Keep a picture in memory", "{\"name\": string, \"base64\": string} — keeps the picture's bytes under its file name for the session; pictures with that file name (Picture mode, Reread, Relink, a merged or opened figure) then load from memory when the file is not on disk. The browser has no files, so its Picture mode uses this", false, store, ), Command { id: "picture.import_objects", label: "Import picture as objects", menu: "", shortcut: "", params: "{\"id\"?} — replaces a PDF picture (default: the selected one) with the first page of its file as Fig objects in one compound that fills the picture's box, at the picture's depth; the importer's warnings go to the message panel. PDF pictures only (an EPS picture cannot be imported)", mutates: true, enabled: can_import_objects, run: import_objects, }, cmd( "picture.edit_image", "Edit picture's image", "{\"id\"} — opens the picture's file in the system's default application (native only)", false, edit_image, ), ] } /// Corners and picture of picture object `id`. fn target(e: &Engine, p: &Value) -> Result<(ObjectId, Point, Point, Picture), EngineError> { let id = ObjectId(num(p, "id")?.round().max(0.0) as u64); let o = e .doc() .get(id) .ok_or_else(|| EngineError::not_found(format!("no top-level object with id {}", id.0)))?; if let ObjectKind::Polyline(pl) = &o.kind { if let (Some(pic), Some(a), Some(b)) = (&pl.picture, pl.points.first(), pl.points.get(2)) { return Ok((id, *a, *b, pic.clone())); } } Err(EngineError::bad_params("object is not a picture")) } fn set_corners(e: &mut Engine, id: ObjectId, a: Point, b: Point) { if let Some(o) = e.doc_mut().get_mut(id) { if let ObjectKind::Polyline(pl) = &mut o.kind { pl.points = vec![a, Point::new(b.x, a.y), b, Point::new(a.x, b.y)]; } } } fn resize(e: &mut Engine, p: &Value) -> CmdResult { let (id, a, b, pic) = target(e, p)?; let mode = match string(p, "mode")?.as_str() { "shrink" => Resize::Shrink, "expand" => Resize::Expand, "original" => Resize::Original, "scale" => Resize::Percent(opt_num(p, "percent")?.unwrap_or(100.0)), _ => { return Err(EngineError::bad_params( "mode must be shrink, expand, original or scale", )) } }; let (w, h) = pic.image.as_ref().map(|i| (i.width, i.height)).unwrap_or((0, 0)); let q = resize_corner(mode, a, b, pic.flipped, w, h) .ok_or_else(|| EngineError::bad_params("the picture has no size or the box no area"))?; if q.x.round() == a.x.round() || q.y.round() == a.y.round() || !q.is_finite() { return Err(EngineError::bad_params("the box would have no area")); } set_corners(e, id, a, q); Ok(json!({"id": id.0, "corner": [q.x, q.y]})) } fn rotate(e: &mut Engine, p: &Value) -> CmdResult { let (id, a, b, _) = target(e, p)?; let deg = num(p, "rotation")?.round() as i32; let q = with_rotation(a, b, deg).ok_or_else(|| EngineError::bad_params("rotation must be 0, 90, 180 or 270"))?; set_corners(e, id, a, q); Ok(json!({"id": id.0, "rotation": deg})) } fn store(_e: &mut Engine, p: &Value) -> CmdResult { let name = string(p, "name")?; let bytes = crate::base64_decode(&string(p, "base64")?); // Only what the image crate or readpics can read is worth keeping. if bytes.is_empty() { return Err(EngineError::bad_params("no picture data")); } import::store_picture(&name, bytes).map_err(EngineError::bad_params)?; Ok(json!({"name": name})) } fn set_file(e: &mut Engine, p: &Value) -> CmdResult { let (id, _, _, pic) = target(e, p)?; let path = match p.get("path") { None | Some(Value::Null) => pic.file.clone(), Some(_) => string(p, "path")?, }; if path.is_empty() { return Err(EngineError::bad_params("the picture has no file name")); } let img = import::load_picture(std::path::Path::new(&path)).map_err(EngineError::io)?; if let Some(o) = e.doc_mut().get_mut(id) { if let ObjectKind::Polyline(pl) = &mut o.kind { if let Some(pic) = &mut pl.picture { pic.file = path.clone(); pic.image = Some(Shared::new(img)); } } } Ok(json!({"id": id.0, "file": path})) } fn info_cmd(e: &mut Engine, p: &Value) -> CmdResult { let (id, a, b, pic) = target(e, p)?; let i = info(&pic); Ok(json!({ "id": id.0, "file": pic.file, "exists": i.exists, "type": i.kind, "width": i.width, "height": i.height, "colours": i.colours, "transparent": i.transparent, "rotation": rotation_of(a, b), "flipped": pic.flipped, })) } fn edit_image(e: &mut Engine, p: &Value) -> CmdResult { let (id, _, _, pic) = target(e, p)?; if !std::path::Path::new(&pic.file).is_file() { return Err(EngineError::not_found(format!("{}: file not found", pic.file))); } let editor = e.prefs.str("image_editor").unwrap_or_default(); open_external(&pic.file, &editor)?; Ok(json!({"id": id.0, "file": pic.file})) } /// The words of an editor command line with `%f` replaced by the file (the /// file is added as the last word when there is no `%f`), as xfig's Image /// editor setting; `None` for xfig's default (the system's own program). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn editor_command(editor: &str, file: &str) -> Option> { let words: Vec<&str> = editor.split_whitespace().collect(); // The system's opener under any system's name means "the default". if words.is_empty() || matches!(editor.trim(), "xdg-open" | "open" | "start") || editor.trim() == crate::prefs::SYSTEM_OPEN { return None; } let mut out: Vec = words.iter().map(|w| w.replace("%f", file)).collect(); if !editor.contains("%f") { out.push(file.to_string()); } Some(out) } #[cfg(not(target_arch = "wasm32"))] fn open_external(file: &str, editor: &str) -> Result<(), EngineError> { use std::process::Command as Process; if let Some(words) = editor_command(editor, file) { let Some((prog, args)) = words.split_first() else { return Err(EngineError::io("the image editor command is empty")); }; return Process::new(prog) .args(args) .spawn() .map(|_| ()) .map_err(|err| EngineError::io(format!("can't start the image editor {prog}: {err}"))); } let mut c = if cfg!(target_os = "macos") { let mut c = Process::new("open"); c.arg(file); c } else if cfg!(target_os = "windows") { let mut c = Process::new("cmd"); c.args(["/C", "start", ""]).arg(file); c } else { let mut c = Process::new("xdg-open"); c.arg(file); c }; c.spawn() .map(|_| ()) .map_err(|err| EngineError::io(format!("can't start an editor for {file}: {err}"))) } #[cfg(target_arch = "wasm32")] fn open_external(_file: &str, _editor: &str) -> Result<(), EngineError> { Err(EngineError::io("Edit Image is not available in the web app")) } // ---- G6: Import as objects ---- /// The affine map of a PDF page (`pw` x `ph` Fig units, origin top-left) onto /// the picture box `min`..`min + (bw, bh)` as the canvas draws the picture: the /// box's own corners, the picture transposed when flipped (Fig's "flipped /// about the diagonal"). pub fn page_to_box(pw: f64, ph: f64, min: Point, bw: f64, bh: f64, flipped: bool) -> rustfig_geom::Affine { use rustfig_geom::Affine; if flipped { Affine { a: 0.0, b: bh / pw.max(1.0e-9), c: bw / ph.max(1.0e-9), d: 0.0, e: min.x, f: min.y, } } else { Affine { a: bw / pw.max(1.0e-9), b: 0.0, c: 0.0, d: bh / ph.max(1.0e-9), e: min.x, f: min.y, } } } /// Whether top-level object `id` is a picture whose file is a PDF (by content). fn is_pdf_picture(e: &Engine, id: ObjectId) -> bool { let Some(o) = e.doc().get(id) else { return false }; let ObjectKind::Polyline(pl) = &o.kind else { return false; }; pl.picture .as_ref() .is_some_and(|pic| pl.points.len() >= 3 && picture_type(&pic.file) == "PDF") } /// G6: enabled while a PDF picture is selected. fn can_import_objects(e: &Engine) -> bool { e.selection().iter().any(|&id| is_pdf_picture(e, id)) } fn import_objects(e: &mut Engine, p: &Value) -> CmdResult { let id = match opt_num(p, "id")? { Some(n) => ObjectId(n.round().max(0.0) as u64), None => e .selection() .iter() .copied() .find(|&id| is_pdf_picture(e, id)) .ok_or_else(|| EngineError::bad_params("select a PDF picture or give an \"id\""))?, }; let (id, a, b, pic) = target(e, &json!({"id": id.0}))?; let dir = e .path() .and_then(|p| std::path::Path::new(p).parent()) .map(std::path::Path::to_path_buf); let path = import::resolve(&pic.file, dir.as_deref()); let bytes: Vec = match std::fs::metadata(&path) { Ok(m) if m.len() > rustfig_format::MAX_FILE_BYTES as u64 => { return Err(EngineError::io(format!("{}: file is too large", pic.file))) } Ok(_) => std::fs::read(&path).map_err(|err| EngineError::io(format!("{}: {err}", pic.file)))?, Err(_) => match import::stored_picture(&pic.file) { Some(b) => b.to_vec(), None => return Err(EngineError::not_found(format!("{}: file not found", pic.file))), }, }; if !import::pdf::is_pdf(&bytes) { return Err(EngineError::bad_params( "only a PDF picture can be imported as objects (an EPS picture cannot)", )); } let r = rustfig_geom::Rect::from_points([a, b]); let (bw, bh) = (r.max.x - r.min.x, r.max.y - r.min.y); if !(bw.is_finite() && bh.is_finite() && bw >= 1.0 && bh >= 1.0) { return Err(EngineError::bad_params("the picture box has no area")); } // The vectors, even when the PDF carries an embedded RustFig figure: the // user asked for objects drawn from the page. let (pw, ph) = import::pdf::page_size(&bytes, 1).map_err(EngineError::format)?; let stem = super::file::pdf_stem(&pic.file); let (doc, warnings) = super::file::load_pdf(&bytes, &json!({}), &stem, dir.as_deref())?; let depth = e.doc().get(id).map(|o| o.depth()).unwrap_or(50); let t = page_to_box(pw, ph, r.min, bw, bh, pic.flipped); let mut objects = super::file::mapped_objects(e, doc); if objects.is_empty() { return Err(EngineError::bad_params("the PDF page has nothing to import")); } for o in &mut objects { o.transform(&t, true); } let mut comp = rustfig_model::Object::new(ObjectKind::Compound(rustfig_model::Compound { objects })); comp.set_depth(depth); let Some(index) = e.doc().index_of(id) else { return Err(EngineError::not_found("the picture is gone")); }; e.doc_mut().objects.remove(index); let new_id = e.add_object(comp); let objs = &mut e.doc_mut().objects; if let Some(o) = objs.pop() { let at = index.min(objs.len()); objs.insert(at, o); } e.set_selection(vec![new_id]); if !warnings.is_empty() { e.set_status(format!("Imported the PDF as objects: {}", warnings.join("; "))); } Ok(json!({"id": new_id.0, "warnings": warnings})) } #[cfg(test)] mod editor_tests { use super::{capture_file_name, editor_command}; #[test] fn image_editor_command_takes_the_file_for_percent_f() { assert_eq!(editor_command("xdg-open", "a.png"), None); assert_eq!(editor_command(" ", "a.png"), None); assert_eq!( editor_command("gimp %f", "a.png"), Some(vec!["gimp".to_string(), "a.png".to_string()]) ); // Without %f the file is the last word. assert_eq!( editor_command("xv -quit", "a.png"), Some(vec!["xv".to_string(), "-quit".to_string(), "a.png".to_string()]) ); } #[test] fn capture_file_name_follows_xfig() { assert_eq!( capture_file_name(Some("/a/b/figure1.fig"), 828_065_129), "figure1_828065129.png" ); assert_eq!(capture_file_name(None, 7), "capture_7.png"); assert_eq!(capture_file_name(Some(""), 7), "capture_7.png"); } } .