diff --git a/Cargo.lock b/Cargo.lock index 6306a5afc9..cf13f7ad12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3524,6 +3524,7 @@ dependencies = [ "unicode-segmentation", "url", "winresource", + "writer_inkml", ] [[package]] @@ -3623,6 +3624,7 @@ dependencies = [ "tracing", "unicode-segmentation", "usvg", + "writer_inkml", "xmlwriter", ] @@ -5098,12 +5100,37 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "writer_inkml" +version = "0.1.0" +source = "git+https://github.com/Doublonmousse/writer_reader_inkml?rev=9370cf6a65ce566d91c232ca47d2cf08008e605e#9370cf6a65ce566d91c232ca47d2cf08008e605e" +dependencies = [ + "anyhow", + "tracing", + "xml", +] + [[package]] name = "xi-unicode" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" +[[package]] +name = "xml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede1c99c55b4b3ad0349018ef0eccbe954ce9c342334410707ee87177fcf2ab4" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "xml-rs" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" + [[package]] name = "xml5ever" version = "0.18.1" diff --git a/Cargo.toml b/Cargo.toml index 8ad41cbf1d..23c0d4caf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,8 @@ xmlwriter = "0.1.0" poppler-rs = { git = "https://github.com/Doublonmousse/poppler-patch", features = [ "v20_9", ], rev = "3acaa4e28d45a05939a71116b0d5ebd4b1c99b98" } +# inkml parser/writer +writer_inkml = {git = "https://github.com/Doublonmousse/writer_reader_inkml", rev = "9370cf6a65ce566d91c232ca47d2cf08008e605e"} [patch.crates-io] diff --git a/crates/rnote-engine/Cargo.toml b/crates/rnote-engine/Cargo.toml index c8add5331c..ca5d4f2a27 100644 --- a/crates/rnote-engine/Cargo.toml +++ b/crates/rnote-engine/Cargo.toml @@ -57,6 +57,7 @@ usvg = { workspace = true } xmlwriter = { workspace = true } # the long-term plan is to remove the gtk4 dependency entirely after switching to another renderer. gtk4 = { workspace = true, optional = true } +writer_inkml = {workspace = true} [dev-dependencies] approx = { workspace = true } diff --git a/crates/rnote-engine/src/engine/strokecontent.rs b/crates/rnote-engine/src/engine/strokecontent.rs index 48f8aceb2b..138a2454ab 100644 --- a/crates/rnote-engine/src/engine/strokecontent.rs +++ b/crates/rnote-engine/src/engine/strokecontent.rs @@ -174,4 +174,15 @@ impl StrokeContent { Ok(()) } + + pub fn to_inkml(&self, current_dpi: f64) -> anyhow::Result> { + writer_inkml::writer( + self.strokes + .iter() + .map(|stroke| stroke.into_inkml(current_dpi)) + .filter(|x| x.is_some()) + .map(|x| x.unwrap()) + .collect(), + ) + } } diff --git a/crates/rnote-engine/src/fileformats/inkmlformat.rs b/crates/rnote-engine/src/fileformats/inkmlformat.rs new file mode 100644 index 0000000000..41af8537f7 --- /dev/null +++ b/crates/rnote-engine/src/fileformats/inkmlformat.rs @@ -0,0 +1,51 @@ +use crate::strokes::BrushStroke; +use crate::strokes::Stroke; +use rnote_compose::Color; +use rnote_compose::PenPath; +use rnote_compose::penpath::Element; +use rnote_compose::style::PressureCurve; +use rnote_compose::style::smooth::SmoothOptions; +use std::sync::Arc; +use writer_inkml::{Brush, FormattedStroke}; + +pub fn inkml_to_stroke( + formatted_stroke: FormattedStroke, + brush: Brush, + dpi: &f64, +) -> Option> { + let mut smooth_options = SmoothOptions::default(); + smooth_options.stroke_color = Some(Color::new( + brush.color.0 as f64 / 255.0, + brush.color.1 as f64 / 255.0, + brush.color.2 as f64 / 255.0, + 1.0 - brush.transparency as f64 / 255.0, + )); + + // converting from cm to px + smooth_options.stroke_width = dpi * brush.stroke_width_cm / 2.54; + + // pressure curve + if brush.ignorepressure { + smooth_options.pressure_curve = PressureCurve::Const; + } else { + smooth_options.pressure_curve = PressureCurve::Linear; + } + + let penpath = PenPath::try_from_elements( + formatted_stroke + .x + .into_iter() + .zip(formatted_stroke.y) + .zip(formatted_stroke.f) + .map(|((x, y), f)| Element::new(*dpi * na::vector![x, y] / 2.54, f)), + ); + if penpath.is_some() { + let new_stroke = BrushStroke::from_penpath( + penpath.unwrap(), + rnote_compose::Style::Smooth(smooth_options), + ); + Some(Arc::new(Stroke::BrushStroke(new_stroke))) + } else { + None + } +} diff --git a/crates/rnote-engine/src/fileformats/mod.rs b/crates/rnote-engine/src/fileformats/mod.rs index cbad8df318..f183ef60e9 100644 --- a/crates/rnote-engine/src/fileformats/mod.rs +++ b/crates/rnote-engine/src/fileformats/mod.rs @@ -1,4 +1,5 @@ // Modules +pub mod inkmlformat; pub mod rnoteformat; pub mod xoppformat; diff --git a/crates/rnote-engine/src/pens/selector/mod.rs b/crates/rnote-engine/src/pens/selector/mod.rs index b1035b86f6..903aab80d8 100644 --- a/crates/rnote-engine/src/pens/selector/mod.rs +++ b/crates/rnote-engine/src/pens/selector/mod.rs @@ -11,6 +11,7 @@ use crate::snap::SnapCorner; use crate::store::StrokeKey; use crate::strokes::Content; use crate::{Camera, DrawableOnDoc, Engine, WidgetFlags}; +use core::str; use futures::channel::oneshot; use kurbo::Shape; use p2d::bounding_volume::{Aabb, BoundingSphere, BoundingVolume}; @@ -171,6 +172,8 @@ impl PenBehaviour for Selector { None }; + let dpi = engine_view.document.config.format.dpi(); + rayon::spawn(move || { let result = move || { if let Some(stroke_content) = stroke_content { @@ -186,6 +189,27 @@ impl PenBehaviour for Selector { serde_json::to_string(&stroke_content)?.into_bytes(), StrokeContent::MIME_TYPE.to_string(), )); + + // add inkml content + let inkml_contents = stroke_content.to_inkml(dpi); + match inkml_contents { + Ok(inkml_bytes) => { + tracing::debug!( + "generated inkml : {:?}", + str::from_utf8(&inkml_bytes) + ); + clipboard_content.push(( + inkml_bytes.clone(), + "application/x.windows.InkML Format".to_string(), + )); + clipboard_content + .push((inkml_bytes, "application/inkml+xml".to_string())); + } + Err(e) => error!( + "Could not convert strokes to inkml to add to the clipboard, {e}" + ), + } + if let Some(stroke_content_svg) = stroke_content_svg { // Add generated Svg clipboard_content.push(( diff --git a/crates/rnote-engine/src/strokes/stroke.rs b/crates/rnote-engine/src/strokes/stroke.rs index d426421554..718ef0b609 100644 --- a/crates/rnote-engine/src/strokes/stroke.rs +++ b/crates/rnote-engine/src/strokes/stroke.rs @@ -13,6 +13,7 @@ use p2d::bounding_volume::Aabb; use rnote_compose::ext::AabbExt; use rnote_compose::penpath::Element; use rnote_compose::shapes::{Rectangle, Shapeable}; +use rnote_compose::style::PressureCurve; use rnote_compose::style::smooth::SmoothOptions; use rnote_compose::transform::Transform; use rnote_compose::transform::Transformable; @@ -672,4 +673,96 @@ impl Stroke { } } } + + /// converts a stroke into the input format used by the inkml writer + pub fn into_inkml( + &self, + current_dpi: f64, + ) -> Option<(writer_inkml::FormattedStroke, writer_inkml::Brush)> { + let pixel_to_cm_factor = 2.54 / current_dpi; + match self { + Stroke::BrushStroke(brushstroke) => { + // remark : style is not preserved here, we will always get a smooth + // version + let fill_color = brushstroke.style.stroke_color().unwrap_or_default(); + let elements = brushstroke.path.clone().into_elements(); + let ignore_pressure = match &brushstroke.style { + Style::Smooth(smooth_options) => match smooth_options.pressure_curve { + PressureCurve::Const => true, + _ => false, + }, + Style::Rough(_) => false, + Style::Textured(_) => false, + }; + tracing::debug!("formatting strokes"); + Some(( + writer_inkml::FormattedStroke { + x: elements + .iter() + .map(|element| pixel_to_cm_factor * element.pos.x) + .collect(), // need the scale ! + y: elements + .iter() + .map(|element| pixel_to_cm_factor * element.pos.y) + .collect(), + f: elements.iter().map(|element| element.pressure).collect(), + }, + writer_inkml::Brush::init( + String::from(""), + ( + (fill_color.r * 255.0) as u8, + (fill_color.g * 255.0) as u8, + (fill_color.b * 255.0) as u8, + ), + ignore_pressure, + ((1.0 - fill_color.a) * 255.0) as u8, + brushstroke.style.stroke_width() * pixel_to_cm_factor, + ), + )) + } + Stroke::ShapeStroke(ShapeStroke { shape, style, .. }) => { + // partial support for shapes + // everything with no fill + let stroke_color = style.stroke_color().unwrap_or_default(); + let brush = writer_inkml::Brush::init( + String::from(""), + ( + (stroke_color.r * 255.0) as u8, + (stroke_color.g * 255.0) as u8, + (stroke_color.b * 255.0) as u8, + ), + true, + ((1.0 - stroke_color.a) * 255.0) as u8, + style.stroke_width() * pixel_to_cm_factor, + ); + let mut out_elements: Vec<(f64, f64)> = vec![]; + kurbo::flatten(shape.outline_path(), 0.25, |path_el| match path_el { + kurbo::PathEl::MoveTo(pt) => out_elements.push((pt.x, pt.y)), + // technically, a moveto should create a new brush + kurbo::PathEl::LineTo(pt) => out_elements.push((pt.x, pt.y)), + kurbo::PathEl::ClosePath => { + out_elements.push(out_elements[0]); + } + _ => {} + }); + // we return ONE stroke at most for now + // only affect arrows for now (though they still render fine ?) + let formatted_stroke = writer_inkml::FormattedStroke { + x: out_elements + .iter() + .map(|element| pixel_to_cm_factor * element.0) + .collect(), // need the scale ! + y: out_elements + .iter() + .map(|element| pixel_to_cm_factor * element.1) + .collect(), + f: out_elements.iter().map(|_| 1.0).collect(), + }; + Some((formatted_stroke, brush)) + } + Stroke::TextStroke(_) => None, + Stroke::VectorImage(_) => None, + Stroke::BitmapImage(_) => None, + } + } } diff --git a/crates/rnote-ui/Cargo.toml b/crates/rnote-ui/Cargo.toml index 426d8e9e00..69148ebbe8 100644 --- a/crates/rnote-ui/Cargo.toml +++ b/crates/rnote-ui/Cargo.toml @@ -55,6 +55,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } unicode-segmentation = { workspace = true } url = { workspace = true } +writer_inkml = { workspace = true} [build-dependencies] anyhow = { workspace = true } diff --git a/crates/rnote-ui/src/appwindow/actions.rs b/crates/rnote-ui/src/appwindow/actions.rs index 740ca2245e..db1422c06f 100644 --- a/crates/rnote-ui/src/appwindow/actions.rs +++ b/crates/rnote-ui/src/appwindow/actions.rs @@ -11,6 +11,7 @@ use p2d::bounding_volume::BoundingVolume; use rnote_compose::SplitOrder; use rnote_compose::penevent::ShortcutKey; use rnote_engine::engine::StrokeContent; +use rnote_engine::fileformats::inkmlformat; use rnote_engine::ext::GraphenePointExt; use rnote_engine::strokes::resize::{ImageSizeOption, Resize}; use rnote_engine::{Camera, Engine}; @@ -1150,7 +1151,7 @@ impl RnAppWindow { respect_borders: appwindow.respect_borders(), }); if let Err(e) = canvas - .insert_stroke_content( + .deserialize_and_insert_stroke_content( json_string.to_string(), resize_argument, target_pos, @@ -1178,6 +1179,85 @@ impl RnAppWindow { }; } )); + } + // test if we see inkml + // mimetype expected : application/inkml+xml + else if content_formats.contain_mime_type("application/inkml+xml") + || content_formats.contain_mime_type("application/x.windows.InkML Format") + { + glib::spawn_future_local(clone!( + #[weak] + canvas, + #[weak(rename_to=appwindow)] + self, + async move { + debug!("Recognized clipboard content: inkml"); + + match appwindow + .clipboard() + .read_future( + &[ + "application/inkml+xml", + "application/x.windows.InkML Format", + ], + glib::source::Priority::DEFAULT, + ) + .await + { + Ok((input_stream, _)) => { + let acc = collect_clipboard_data(input_stream).await; + + if !acc.is_empty() { + match crate::utils::str_from_u8_nul_utf8(&acc) { + Ok(text) => { + let stroke_result = writer_inkml::parse_formatted(text.as_bytes()); + let dpi = canvas.engine_ref().document.config.format.dpi(); + + debug!("stroke result {:?}", stroke_result); + + if let Ok(strokes) = stroke_result { + let generated_strokes = strokes.into_iter().map(|(formatted_stroke,brush)| { + inkmlformat::inkml_to_stroke( + formatted_stroke,brush, &dpi + )}) + .filter(|x| x.is_some()) + .map(|x| x.unwrap()) + .collect(); + + let mut stroke_content = StrokeContent { + strokes: generated_strokes, + bounds:None, + background:None + }; + stroke_content.bounds = stroke_content.bounds(); + + let resize_argument = ImageSizeOption::ResizeImage(Resize { + width: canvas.engine_ref().document.config.format.width(), + height: canvas.engine_ref().document.config.format.height(), + layout_fixed_width: canvas.engine_ref().document.config.layout.is_fixed_width(), + max_viewpoint: None, + restrain_to_viewport: false, + respect_borders: appwindow.respect_borders(), + }); + if let Err(e) = canvas.insert_stroke_content(stroke_content, resize_argument, target_pos).await { + error!("Failed to insert stroke content while pasting as `inkml`, Err: {e:?}"); + } + } else { + error!("could not parse the inkml file"); + } + } + Err(e) => error!("Failed to get string from clipboard data while pasting as inkml, Err: {e:?}"), + } + } + } + Err(e) => { + error!( + "Failed to read clipboard data while pasting as inkml, Err: {e:?}" + ); + } + }; + } + )); } else if content_formats.contain_mime_type("image/svg+xml") { glib::spawn_future_local(clone!( #[weak(rename_to=appwindow)] diff --git a/crates/rnote-ui/src/canvas/imexport.rs b/crates/rnote-ui/src/canvas/imexport.rs index abcf88a99f..9d7adbaa7d 100644 --- a/crates/rnote-ui/src/canvas/imexport.rs +++ b/crates/rnote-ui/src/canvas/imexport.rs @@ -180,7 +180,7 @@ impl RnCanvas { /// Deserializes the stroke content and inserts it into the engine. /// /// The data is usually coming from the clipboard, drop source, etc. - pub(crate) async fn insert_stroke_content( + pub(crate) async fn deserialize_and_insert_stroke_content( &self, json_string: String, resize_option: ImageSizeOption, @@ -209,6 +209,25 @@ impl RnCanvas { Ok(()) } + /// Take stroke content and inserts it into the engine. + /// + /// The data is usually coming from the clipboard, drop source, etc. + pub(crate) async fn insert_stroke_content( + &self, + stroke_content: StrokeContent, + resize_option: ImageSizeOption, + target_pos: Option>, + ) -> anyhow::Result<()> { + let pos = self.determine_stroke_import_pos(target_pos); + + let widget_flags = + self.engine_mut() + .insert_stroke_content(stroke_content, pos, resize_option); + + self.emit_handle_widget_flags(widget_flags); + Ok(()) + } + /// Saves the document to the given file. /// /// Returns Ok(true) if saved successfully, Ok(false) when a save is already in progress and no file operatiosn were