diff --git a/Cargo.lock b/Cargo.lock index 565206ec35..2452cf2bbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -374,6 +374,30 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +[[package]] +name = "bitcode" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1bce7608560cd4bf0296a4262d0dbf13e6bcec5ff2105724c8ab88cc7fc784" +dependencies = [ + "arrayvec", + "bitcode_derive", + "bytemuck", + "glam", + "serde", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a539389a13af092cd345a2b47ae7dec12deb306d660b2223d25cd3419b253ebe" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1444,6 +1468,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" + [[package]] name = "glib" version = "0.20.4" @@ -3364,10 +3394,13 @@ version = "0.11.0" dependencies = [ "anyhow", "approx 0.5.1", + "async-fs", "base64", + "bitcode", "cairo-rs", "chrono", "clap", + "crc32fast", "flate2", "futures", "geo", @@ -3408,6 +3441,7 @@ dependencies = [ "unicode-segmentation", "usvg", "xmlwriter", + "zstd", ] [[package]] @@ -4884,6 +4918,34 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 1bd51ecdee..5776da0dd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,9 +25,11 @@ anyhow = "1.0" approx = "0.5.1" async-fs = "2.1" base64 = "0.22.1" +bitcode = { version = "0.6", features = ["serde"] } cairo-rs = { version = "0.20.1", features = ["v1_18", "png", "svg", "pdf"] } chrono = "0.4.38" clap = { version = "4.5", features = ["derive"] } +crc32fast = { version = "1.4" } dialoguer = "0.11.0" flate2 = "1.0" fs_extra = "1.3" @@ -85,6 +87,7 @@ winresource = "0.1.17" xmlwriter = "0.1.0" # Enabling feature > v20_9 causes linker errors on mingw poppler-rs = { version = "0.24.1", features = ["v20_9"] } +zstd = { version = "0.13", features = ["zstdmt"] } [patch.crates-io] # once a new piet (current v0.6.2) is released with updated cairo and kurbo deps, this can be removed. diff --git a/crates/rnote-cli/src/cli.rs b/crates/rnote-cli/src/cli.rs index f135bc5218..ead7f3521c 100644 --- a/crates/rnote-cli/src/cli.rs +++ b/crates/rnote-cli/src/cli.rs @@ -1,6 +1,7 @@ // Imports -use crate::{export, import, test}; +use crate::{export, import, mutate, test}; use anyhow::Context; +use clap::builder::PossibleValuesParser; use clap::Parser; use rnote_compose::SplitOrder; use rnote_engine::engine::export::{ @@ -8,6 +9,7 @@ use rnote_engine::engine::export::{ SelectionExportPrefs, }; use rnote_engine::engine::import::XoppImportPrefs; +use rnote_engine::fileformats::rnoteformat; use rnote_engine::SelectionCollision; use smol::fs::File; use smol::io::{AsyncReadExt, AsyncWriteExt}; @@ -69,6 +71,27 @@ pub(crate) enum Command { #[arg(long, action = clap::ArgAction::SetTrue, global = true)] open: bool, }, + /// Mutates one or more of the following for the specified Rnote file(s):{n} + /// compression method, compression level, serialization method, method lock + Mutate { + /// The rnote save file(s) to mutate + rnote_files: Vec, + /// Keep the original rnote save file(s) + #[arg(long = "not-in-place", alias = "nip", action = clap::ArgAction::SetTrue)] + not_in_place: bool, + /// Sets method_lock to true, allowing a rnote save file to keep using non-default methods to serialize and compress itself + #[arg(short = 'l', long, action = clap::ArgAction::SetTrue, conflicts_with = "unlock")] + lock: bool, + /// Sets method_lock to false, coercing the file to use default methods on the next save + #[arg(short = 'u', long, action = clap::ArgAction::SetTrue, conflicts_with = "lock")] + unlock: bool, + #[arg(short = 's', long, action = clap::ArgAction::Set, value_parser = PossibleValuesParser::new(rnoteformat::SerializationMethod::VALID_STR_ARRAY))] + serialization_method: Option, + #[arg(short = 'c', long, action = clap::ArgAction::Set, value_parser = PossibleValuesParser::new(rnoteformat::CompressionMethod::VALID_STR_ARRAY))] + compression_method: Option, + #[arg(short = 'v', long, action = clap::ArgAction::Set)] + compression_level: Option, + }, } #[derive(clap::ValueEnum, Debug, Clone, Copy, Default)] @@ -242,6 +265,28 @@ pub(crate) async fn run() -> anyhow::Result<()> { .await?; println!("Export finished!"); } + Command::Mutate { + rnote_files, + not_in_place, + lock, + unlock, + serialization_method, + compression_method, + compression_level, + } => { + println!("Mutating..\n"); + mutate::run_mutate( + rnote_files, + not_in_place, + lock, + unlock, + serialization_method, + compression_method, + compression_level, + ) + .await?; + println!("Mutate finished!"); + } } Ok(()) diff --git a/crates/rnote-cli/src/main.rs b/crates/rnote-cli/src/main.rs index 0a6c875cf6..7d99e89596 100644 --- a/crates/rnote-cli/src/main.rs +++ b/crates/rnote-cli/src/main.rs @@ -6,6 +6,7 @@ pub(crate) mod cli; pub(crate) mod export; pub(crate) mod import; +pub(crate) mod mutate; pub(crate) mod test; pub(crate) mod validators; diff --git a/crates/rnote-cli/src/meson.build b/crates/rnote-cli/src/meson.build index 3be7f85868..56edf425ff 100644 --- a/crates/rnote-cli/src/meson.build +++ b/crates/rnote-cli/src/meson.build @@ -4,6 +4,7 @@ rnote_cli_sources = files( 'export.rs', 'import.rs', 'main.rs', + 'mutate.rs', 'test.rs', 'validators.rs', ) diff --git a/crates/rnote-cli/src/mutate.rs b/crates/rnote-cli/src/mutate.rs new file mode 100644 index 0000000000..1e284e9c33 --- /dev/null +++ b/crates/rnote-cli/src/mutate.rs @@ -0,0 +1,94 @@ +use rnote_engine::engine::EngineSnapshot; +use rnote_engine::fileformats::rnoteformat::RnoteHeader; +use rnote_engine::fileformats::FileFormatSaver; +use rnote_engine::fileformats::{rnoteformat::RnoteFile, FileFormatLoader}; +use smol::{fs::OpenOptions, io::AsyncReadExt}; +use std::path::PathBuf; +use std::str::FromStr; + +pub(crate) async fn run_mutate( + rnote_files: Vec, + not_in_place: bool, + lock: bool, + unlock: bool, + serialization_method: Option, + compression_method: Option, + compression_level: Option, +) -> anyhow::Result<()> { + let total_len = rnote_files.len(); + let mut total_delta: f64 = 0.0; + for (idx, mut filepath) in rnote_files.into_iter().enumerate() { + println!("Working on file {} out of {}", idx + 1, total_len); + + let file_read_operation = async { + let mut read_file = OpenOptions::new().read(true).open(&filepath).await?; + + let mut bytes: Vec = { + match read_file.metadata().await { + Ok(metadata) => { + Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(usize::MAX)) + } + Err(err) => { + eprintln!("Failed to read file metadata, '{err}'"); + Vec::new() + } + } + }; + + read_file.read_to_end(&mut bytes).await?; + Ok::, anyhow::Error>(bytes) + }; + let bytes = file_read_operation.await?; + let old_size_mb = bytes.len() as f64 / 1e6; + let rnote_file = RnoteFile::load_from_bytes(&bytes)?; + + let serialization = if let Some(ref str) = serialization_method { + rnote_engine::fileformats::rnoteformat::SerializationMethod::from_str(str).unwrap() + } else { + rnote_file.header.serialization + }; + + let mut compression = if let Some(ref str) = compression_method { + rnote_engine::fileformats::rnoteformat::CompressionMethod::from_str(str).unwrap() + } else { + rnote_file.header.compression + }; + + if let Some(lvl) = compression_level { + compression.update_compression_level(lvl)?; + } + + let method_lock = (rnote_file.header.method_lock | lock) && !unlock; + let uc_data = serialization.serialize(&EngineSnapshot::try_from(rnote_file)?)?; + let uc_size = uc_data.len() as u64; + let data = compression.compress(uc_data)?; + + let rnote_file = RnoteFile { + header: RnoteHeader { + serialization, + compression, + uc_size, + method_lock, + }, + body: data, + }; + + if not_in_place { + let file_stem = filepath + .file_stem() + .ok_or_else(|| anyhow::anyhow!("File does not contain a valid file stem"))? + .to_str() + .ok_or_else(|| anyhow::anyhow!("File does not contain a valid file stem"))?; + filepath.set_file_name(format!("{}_mut.rnote", file_stem)); + } + + let data = rnote_file.save_as_bytes("")?; + let new_size_mb = data.len() as f64 / 1e6; + rnote_engine::utils::atomic_save_to_file(&filepath, &data).await?; + + println!("{:.2} MB → {:.2} MB", old_size_mb, new_size_mb,); + total_delta += new_size_mb - old_size_mb; + } + println!("\n⇒ ∆ = {:.2} MB", total_delta); + Ok(()) +} diff --git a/crates/rnote-engine/Cargo.toml b/crates/rnote-engine/Cargo.toml index c8add5331c..aea64a62a6 100644 --- a/crates/rnote-engine/Cargo.toml +++ b/crates/rnote-engine/Cargo.toml @@ -13,10 +13,13 @@ rnote-compose = { workspace = true } anyhow = { workspace = true } approx = { workspace = true } +async-fs = { workspace = true } base64 = { workspace = true } +bitcode = { workspace = true, features = ["serde"] } cairo-rs = { workspace = true } chrono = { workspace = true } clap = { workspace = true, optional = true } +crc32fast = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } geo = { workspace = true } @@ -55,6 +58,7 @@ tracing = { workspace = true } unicode-segmentation = { workspace = true } usvg = { workspace = true } xmlwriter = { workspace = true } +zstd = { workspace = true, features = ["zstdmt"] } # the long-term plan is to remove the gtk4 dependency entirely after switching to another renderer. gtk4 = { workspace = true, optional = true } diff --git a/crates/rnote-engine/src/engine/export.rs b/crates/rnote-engine/src/engine/export.rs index 7c592379d3..2a4b55e4f1 100644 --- a/crates/rnote-engine/src/engine/export.rs +++ b/crates/rnote-engine/src/engine/export.rs @@ -331,9 +331,7 @@ impl Engine { let engine_snapshot = self.take_snapshot(); rayon::spawn(move || { let result = || -> anyhow::Result> { - let rnote_file = RnoteFile { - engine_snapshot: ijson::to_value(&engine_snapshot)?, - }; + let rnote_file = RnoteFile::try_from(&engine_snapshot)?; rnote_file.save_as_bytes(&file_name) }; if oneshot_sender.send(result()).is_err() { @@ -353,6 +351,7 @@ impl Engine { penholder: self.penholder.clone_config(), import_prefs: self.import_prefs.clone_config(), export_prefs: self.export_prefs.clone_config(), + save_prefs: self.save_prefs.clone_conformed_config(), pen_sounds: self.pen_sounds(), optimize_epd: self.optimize_epd(), } diff --git a/crates/rnote-engine/src/engine/import.rs b/crates/rnote-engine/src/engine/import.rs index 7ba5359f31..750f247539 100644 --- a/crates/rnote-engine/src/engine/import.rs +++ b/crates/rnote-engine/src/engine/import.rs @@ -161,6 +161,7 @@ impl Engine { self.penholder = engine_config.penholder; self.import_prefs = engine_config.import_prefs; self.export_prefs = engine_config.export_prefs; + self.save_prefs = engine_config.save_prefs; // Set the pen sounds to update the audioplayer self.set_pen_sounds(engine_config.pen_sounds, data_dir); diff --git a/crates/rnote-engine/src/engine/mod.rs b/crates/rnote-engine/src/engine/mod.rs index 2e7ad19093..ef094fb072 100644 --- a/crates/rnote-engine/src/engine/mod.rs +++ b/crates/rnote-engine/src/engine/mod.rs @@ -2,6 +2,7 @@ pub mod export; pub mod import; pub mod rendering; +pub mod save; pub mod snapshot; pub mod strokecontent; pub mod visual_debug; @@ -11,6 +12,7 @@ pub use export::ExportPrefs; use futures::channel::mpsc::UnboundedReceiver; use futures::StreamExt; pub use import::ImportPrefs; +pub use save::CompressionLevel; pub use snapshot::EngineSnapshot; pub use strokecontent::StrokeContent; @@ -30,6 +32,7 @@ use rnote_compose::eventresult::EventPropagation; use rnote_compose::ext::AabbExt; use rnote_compose::penevent::{PenEvent, ShortcutKey}; use rnote_compose::{Color, SplitOrder}; +use save::SavePrefs; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; @@ -119,6 +122,8 @@ pub struct EngineConfig { import_prefs: ImportPrefs, #[serde(rename = "export_prefs")] export_prefs: ExportPrefs, + #[serde(rename = "save_prefs")] + save_prefs: SavePrefs, #[serde(rename = "pen_sounds")] pen_sounds: bool, #[serde(rename = "optimize_epd")] @@ -167,6 +172,8 @@ pub struct Engine { pub import_prefs: ImportPrefs, #[serde(rename = "export_prefs")] pub export_prefs: ExportPrefs, + #[serde(rename = "save_prefs")] + pub save_prefs: SavePrefs, #[serde(rename = "pen_sounds")] pen_sounds: bool, #[serde(rename = "optimize_epd")] @@ -207,6 +214,7 @@ impl Default for Engine { penholder: PenHolder::default(), import_prefs: ImportPrefs::default(), export_prefs: ExportPrefs::default(), + save_prefs: SavePrefs::default(), pen_sounds: false, optimize_epd: false, @@ -328,6 +336,7 @@ impl Engine { stroke_components: Arc::clone(&store_history_entry.stroke_components), chrono_components: Arc::clone(&store_history_entry.chrono_components), chrono_counter: store_history_entry.chrono_counter, + save_prefs: self.save_prefs.clone_config(), } } @@ -335,6 +344,8 @@ impl Engine { pub fn load_snapshot(&mut self, snapshot: EngineSnapshot) -> WidgetFlags { self.document = snapshot.document.clone_config(); self.camera = snapshot.camera.clone_config(); + self.save_prefs = snapshot.save_prefs.clone_config(); + let mut widget_flags = self.store.import_from_snapshot(&snapshot) | self.doc_resize_autoexpand() | self.current_pen_update_state() diff --git a/crates/rnote-engine/src/engine/save.rs b/crates/rnote-engine/src/engine/save.rs new file mode 100644 index 0000000000..0253606dbc --- /dev/null +++ b/crates/rnote-engine/src/engine/save.rs @@ -0,0 +1,142 @@ +// Imports +use crate::fileformats::rnoteformat::{ + methods::{CompressionMethod, SerializationMethod}, + RnoteHeader, +}; +use serde::{Deserialize, Serialize}; +use std::mem::discriminant; + +/// Rnote file save preferences, a subset of RnoteHeader +/// used by EngineSnapshot, Engine, and EngineConfig +/// +/// when loading in an Rnote file, SavePrefs will be created from RnoteHeader +/// if RnoteHeader's serialization and compression methods conform to the defaults, or method_lock is set to true +/// => SavePrefs override EngineSnapshot's default SavePrefs +/// => SavePrefs transferred from EngineSnapshot to Engine +/// +/// as for EngineConfig, if and only if Engine's SavePrefs conform to the defaults +/// => SavePrefs cloned from Engine into EngineConfig +/// +/// please note that the compression level is not used to check whether or not the methods conform to the defaults +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename = "save_prefs")] +pub struct SavePrefs { + #[serde(rename = "serialization")] + pub serialization: SerializationMethod, + #[serde(rename = "compression")] + pub compression: CompressionMethod, + #[serde(rename = "method_lock")] + pub method_lock: bool, +} + +impl SavePrefs { + pub fn clone_config(&self) -> Self { + self.clone() + } + pub fn conforms_to_default(&self) -> bool { + discriminant(&self.serialization) == discriminant(&SerializationMethod::default()) + && discriminant(&self.compression) == discriminant(&CompressionMethod::default()) + } + /// The EngineExport should only contain SavePrefs that conform to the default + /// otherwise for example, after having opened an uncompressed and JSON-encoded Rnote + /// save file while debugging, all new save files would be using the same methods + pub fn clone_conformed_config(&self) -> Self { + if self.conforms_to_default() { + self.clone_config() + } else { + Self::default() + } + } +} + +impl Default for SavePrefs { + fn default() -> Self { + Self { + serialization: SerializationMethod::default(), + compression: CompressionMethod::default(), + method_lock: false, + } + } +} + +impl From for SavePrefs { + fn from(value: RnoteHeader) -> Self { + Self { + serialization: value.serialization, + compression: value.compression, + method_lock: value.method_lock, + } + } +} + +#[derive(Debug, Clone, Copy, num_derive::FromPrimitive, num_derive::ToPrimitive)] +pub enum CompressionLevel { + VeryHigh, + High, + Medium, + Low, + VeryLow, + None, +} + +impl TryFrom for CompressionLevel { + type Error = anyhow::Error; + + fn try_from(value: u32) -> Result { + num_traits::FromPrimitive::from_u32(value).ok_or_else(|| { + anyhow::anyhow!( + "CompressionLevel try_from::() for value {} failed", + value + ) + }) + } +} + +impl CompressionMethod { + pub fn get_compression_level(&self) -> CompressionLevel { + match self { + Self::None => CompressionLevel::None, + Self::Gzip(val) => match *val { + 0..=1 => CompressionLevel::VeryLow, + 2..=3 => CompressionLevel::Low, + 4..=5 => CompressionLevel::Medium, + 6..=7 => CompressionLevel::High, + 8..=9 => CompressionLevel::VeryHigh, + _ => unreachable!(), + }, + Self::Zstd(val) => match *val { + 0..=4 => CompressionLevel::VeryLow, + 5..=8 => CompressionLevel::Low, + 9..=12 => CompressionLevel::Medium, + 13..=16 => CompressionLevel::High, + 17..=22 => CompressionLevel::VeryHigh, + _ => unreachable!(), + }, + } + } + pub fn set_compression_level(&mut self, level: CompressionLevel) { + match self { + Self::None => (), + Self::Gzip(ref mut val) => { + *val = match level { + CompressionLevel::VeryHigh => 8, + CompressionLevel::High => 6, + CompressionLevel::Medium => 5, + CompressionLevel::Low => 3, + CompressionLevel::VeryLow => 1, + CompressionLevel::None => unreachable!(), + } + } + Self::Zstd(ref mut val) => { + *val = match level { + CompressionLevel::VeryHigh => 17, + CompressionLevel::High => 13, + CompressionLevel::Medium => 9, + CompressionLevel::Low => 5, + CompressionLevel::VeryLow => 1, + CompressionLevel::None => unreachable!(), + } + } + } + } +} diff --git a/crates/rnote-engine/src/engine/snapshot.rs b/crates/rnote-engine/src/engine/snapshot.rs index 233bbea31b..a3bf3d5541 100644 --- a/crates/rnote-engine/src/engine/snapshot.rs +++ b/crates/rnote-engine/src/engine/snapshot.rs @@ -12,6 +12,8 @@ use slotmap::{HopSlotMap, SecondaryMap}; use std::sync::Arc; use tracing::error; +use super::save::SavePrefs; + // An engine snapshot, used when loading/saving the current document from/into a file. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename = "engine_snapshot")] @@ -26,6 +28,9 @@ pub struct EngineSnapshot { pub chrono_components: Arc>>, #[serde(rename = "chrono_counter")] pub chrono_counter: u32, + // save_prefs is skipped as it is extracted and incorporated into the header when saving + #[serde(skip, default)] + pub save_prefs: SavePrefs, } impl Default for EngineSnapshot { @@ -36,6 +41,7 @@ impl Default for EngineSnapshot { stroke_components: Arc::new(HopSlotMap::with_key()), chrono_components: Arc::new(SecondaryMap::new()), chrono_counter: 0, + save_prefs: SavePrefs::default(), } } } @@ -49,9 +55,20 @@ impl EngineSnapshot { rayon::spawn(move || { let result = || -> anyhow::Result { + // support for legacy files + // gzip magic number + if bytes + .get(..2) + .ok_or_else(|| anyhow::anyhow!("Not an Rnote file"))? + == [0x1f, 0x8b] + { + let legacy = rnoteformat::legacy::LegacyRnoteFile::load_from_bytes(&bytes)?; + return Ok(ijson::from_value(&legacy.engine_snapshot)?); + } + let rnote_file = rnoteformat::RnoteFile::load_from_bytes(&bytes) - .context("loading RnoteFile from bytes failed.")?; - Ok(ijson::from_value(&rnote_file.engine_snapshot)?) + .context("Loading RnoteFile from bytes failed.")?; + Self::try_from(rnote_file) }; if let Err(_data) = snapshot_sender.send(result()) { diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/maj0min5patch8.rs b/crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min5patch8.rs similarity index 100% rename from crates/rnote-engine/src/fileformats/rnoteformat/maj0min5patch8.rs rename to crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min5patch8.rs diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/maj0min5patch9.rs b/crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min5patch9.rs similarity index 100% rename from crates/rnote-engine/src/fileformats/rnoteformat/maj0min5patch9.rs rename to crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min5patch9.rs diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/maj0min6.rs b/crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min6.rs similarity index 100% rename from crates/rnote-engine/src/fileformats/rnoteformat/maj0min6.rs rename to crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min6.rs diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/maj0min9.rs b/crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min9.rs similarity index 100% rename from crates/rnote-engine/src/fileformats/rnoteformat/maj0min9.rs rename to crates/rnote-engine/src/fileformats/rnoteformat/legacy/maj0min9.rs diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/legacy/mod.rs b/crates/rnote-engine/src/fileformats/rnoteformat/legacy/mod.rs new file mode 100644 index 0000000000..cd96d92143 --- /dev/null +++ b/crates/rnote-engine/src/fileformats/rnoteformat/legacy/mod.rs @@ -0,0 +1,107 @@ +// Modules +mod maj0min5patch8; +mod maj0min5patch9; +mod maj0min6; +pub mod maj0min9; + +// Imports +use super::FileFormatLoader; +use anyhow::Context; +use maj0min5patch8::RnoteFileMaj0Min5Patch8; +use maj0min5patch9::RnoteFileMaj0Min5Patch9; +use maj0min6::RnoteFileMaj0Min6; +use maj0min9::RnoteFileMaj0Min9; +use serde::{Deserialize, Serialize}; +use std::io::Read; + +/// Decompress from gzip. +fn decompress_from_gzip(compressed: &[u8]) -> Result, anyhow::Error> { + // Optimization for the gzip format, defined by RFC 1952 + // capacity of the vector defined by the size of the uncompressed data + // given in little endian format, by the last 4 bytes of "compressed" + // + // ISIZE (Input SIZE) + // This contains the size of the original (uncompressed) input data modulo 2^32. + let mut bytes: Vec = { + let mut decompressed_size: [u8; 4] = [0; 4]; + let idx_start = compressed + .len() + .checked_sub(4) + // only happens if the file has less than 4 bytes + .ok_or_else(|| { + anyhow::anyhow!("Not a valid gzip-compressed file") + .context("Failed to get the size of the decompressed data") + })?; + decompressed_size.copy_from_slice(&compressed[idx_start..]); + // u32 -> usize to avoid issues on 32-bit architectures + // also more reasonable since the uncompressed size is given by 4 bytes + Vec::with_capacity(u32::from_le_bytes(decompressed_size) as usize) + }; + + let mut decoder = flate2::read::MultiGzDecoder::new(compressed); + decoder.read_to_end(&mut bytes)?; + Ok(bytes) +} + +/// The rnote file wrapper. +/// +/// Used to extract and match the version up front, before deserializing the data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename = "rnotefile_wrapper")] +struct LegacyRnoteFileWrapper { + #[serde(rename = "version")] + version: semver::Version, + #[serde(rename = "data")] + data: ijson::IValue, +} + +pub type LegacyRnoteFile = RnoteFileMaj0Min9; + +impl FileFormatLoader for LegacyRnoteFile { + fn load_from_bytes(bytes: &[u8]) -> anyhow::Result { + let wrapper = + serde_json::from_slice::(&decompress_from_gzip(bytes)?) + .context("Deserializing RnotefileWrapper from bytes failed")?; + + // Conversions for older file format versions happen here + if semver::VersionReq::parse(">=0.9.0") + .unwrap() + .matches(&wrapper.version) + { + ijson::from_value::(&wrapper.data) + .context("Deserializing RnoteFileMaj0Min9 failed") + } else if semver::VersionReq::parse(">=0.5.10") + .unwrap() + .matches(&wrapper.version) + { + ijson::from_value::(&wrapper.data) + .context("Deserializing RnoteFileMaj0Min6 failed") + .and_then(RnoteFileMaj0Min9::try_from) + .context("Converting RnoteFileMaj0Min6 to newest file version failed") + } else if semver::VersionReq::parse(">=0.5.9") + .unwrap() + .matches(&wrapper.version) + { + ijson::from_value::(&wrapper.data) + .context("Deserializing RnoteFileMaj0Min5Patch9 failed") + .and_then(RnoteFileMaj0Min6::try_from) + .and_then(RnoteFileMaj0Min9::try_from) + .context("Converting RnoteFileMaj0Min5Patch9 to newest file version failed") + } else if semver::VersionReq::parse(">=0.5.0") + .unwrap() + .matches(&wrapper.version) + { + ijson::from_value::(&wrapper.data) + .context("Deserializing RnoteFileMaj0Min5Patch8 failed") + .and_then(RnoteFileMaj0Min5Patch9::try_from) + .and_then(RnoteFileMaj0Min6::try_from) + .and_then(RnoteFileMaj0Min9::try_from) + .context("Converting RnoteFileMaj0Min5Patch8 to newest file version failed") + } else { + Err(anyhow::anyhow!( + "Failed to load rnote file from bytes, unsupported version: '{}'", + wrapper.version + )) + } + } +} diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/maj0min12.rs b/crates/rnote-engine/src/fileformats/rnoteformat/maj0min12.rs new file mode 100644 index 0000000000..31bc3ea6ce --- /dev/null +++ b/crates/rnote-engine/src/fileformats/rnoteformat/maj0min12.rs @@ -0,0 +1,61 @@ +use super::{ + legacy::maj0min9::RnoteFileMaj0Min9, + methods::{CompressionMethod, SerializationMethod}, +}; +use serde::{Deserialize, Serialize}; + +/// # Rnote File Format Specifications +/// +/// ## Prelude (not included in this struct, u16,u32,... are represented using little endian) +/// * magic number: [u8; 9] = [0x52, 0x4e, 0x4f, 0x54, 0x45, 0xce, 0xa6, 0xce, 0x9b], "RNOTEϕλ" +/// * version: [u64, u64, u64, u16, str, u16, str] (almost one-to-one representation of semver::Version) +/// [major, minor, patch, Prerelease size, Prerelease, BuildMetadata size, Buildmetadata] +/// * header size: u32 +/// +/// ## Header +/// a forward-compatible json-encoded struct +/// * serialization: method used to serialize/deserialize the engine snapshot +/// * compression: method used to compress/decompress the serialized engine snapshot +/// * uncompressed size: size of the uncompressed and serialized engine snapshot +/// * method lock: if set to true, the file can keep using non-standard methods and will not be forced back into using defaults +/// +/// ## Body +/// the body contains the serialized and (potentially) compressed engine snapshot +#[derive(Debug, Clone)] +pub struct RnoteFileMaj0Min12 { + /// called header and not head because head = prelude + header + pub header: RnoteHeaderMaj0Min12, + /// A serialized and (potentially) compressed engine snapshot + pub body: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename = "header")] +pub struct RnoteHeaderMaj0Min12 { + /// method used to serialize/deserialize the engine snapshot + #[serde(rename = "serialization")] + pub serialization: SerializationMethod, + /// method used to compress/decompress the serialized engine snapshot + #[serde(rename = "compression")] + pub compression: CompressionMethod, + /// size of the uncompressed and serialized engine snapshot + #[serde(rename = "uncompressed_size")] + pub uc_size: u64, + #[serde(rename = "method_lock")] + pub method_lock: bool, +} + +impl TryFrom for RnoteFileMaj0Min12 { + type Error = anyhow::Error; + fn try_from(value: RnoteFileMaj0Min9) -> Result { + Ok(Self { + header: RnoteHeaderMaj0Min12 { + serialization: SerializationMethod::Json, + compression: CompressionMethod::None, + uc_size: 0, + method_lock: false, + }, + body: serde_json::to_vec(&value.engine_snapshot)?, + }) + } +} diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/methods.rs b/crates/rnote-engine/src/fileformats/rnoteformat/methods.rs new file mode 100644 index 0000000000..a6daafacdf --- /dev/null +++ b/crates/rnote-engine/src/fileformats/rnoteformat/methods.rs @@ -0,0 +1,150 @@ +// Imports +use crate::engine::EngineSnapshot; +use serde::{Deserialize, Serialize}; +use std::{ + io::{Read, Write}, + str::FromStr, +}; + +/// Compression methods that can be applied to the serialized engine snapshot +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CompressionMethod { + #[serde(rename = "none")] + None, + #[serde(rename = "gzip")] + Gzip(u8), + /// Zstd supports negative compression levels but I don't see the point in allowing these for Rnote files + #[serde(rename = "zstd")] + Zstd(u8), +} + +/// Serialization methods that can be applied to a snapshot of the engine +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SerializationMethod { + #[serde(rename = "bitcode")] + Bitcode, + #[serde(rename = "json")] + Json, +} + +impl CompressionMethod { + pub fn compress(&self, data: Vec) -> anyhow::Result> { + match self { + Self::None => Ok(data), + Self::Gzip(compression_level) => { + let mut encoder = flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::new(u32::from(*compression_level)), + ); + encoder.write_all(&data)?; + Ok(encoder.finish()?) + } + Self::Zstd(compression_level) => { + let mut encoder = + zstd::Encoder::new(Vec::::new(), i32::from(*compression_level))?; + if let Ok(num_workers) = std::thread::available_parallelism() { + encoder.multithread(num_workers.get() as u32)?; + } + encoder.write_all(&data)?; + Ok(encoder.finish()?) + } + } + } + pub fn decompress(&self, uc_size: usize, data: Vec) -> anyhow::Result> { + match self { + Self::None => Ok(data), + Self::Gzip { .. } => { + let mut bytes: Vec = Vec::with_capacity(uc_size); + let mut decoder = flate2::read::MultiGzDecoder::new(&data[..]); + decoder.read_to_end(&mut bytes)?; + Ok(bytes) + } + Self::Zstd { .. } => { + let mut bytes: Vec = Vec::with_capacity(uc_size); + let mut decoder = zstd::Decoder::new(&data[..])?; + decoder.read_to_end(&mut bytes)?; + Ok(bytes) + } + } + } + pub fn update_compression_level(&mut self, new: u8) -> anyhow::Result<()> { + match self { + Self::None => { + tracing::warn!("Cannot update the compression level of 'None'"); + Ok(()) + } + Self::Gzip(ref mut curr) => { + if !(0..=9).contains(&new) { + Err(anyhow::anyhow!( + "Invalid compression level for Gzip, expected a value between 0 and 9" + )) + } else { + *curr = new; + Ok(()) + } + } + Self::Zstd(ref mut curr) => { + if !zstd::compression_level_range().contains(&i32::from(new)) { + Err(anyhow::anyhow!( + "Invalid compression level for Zstd, expected a value between 0 and 22" + )) + } else { + *curr = new; + Ok(()) + } + } + } + } + pub const VALID_STR_ARRAY: [&'static str; 6] = ["None", "none", "Gzip", "gzip", "Zstd", "zstd"]; +} + +impl Default for CompressionMethod { + fn default() -> Self { + Self::Zstd(9) + } +} + +impl FromStr for CompressionMethod { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s { + "None" | "none" => Ok(Self::None), + "Gzip" | "gzip" => Ok(Self::Gzip(5)), + "Zstd" | "zstd" => Ok(Self::Zstd(9)), + _ => Err("Unknown compression method"), + } + } +} + +impl SerializationMethod { + pub fn serialize(&self, engine_snapshot: &EngineSnapshot) -> anyhow::Result> { + match self { + Self::Bitcode => Ok(bitcode::serialize(engine_snapshot)?), + Self::Json => Ok(serde_json::to_vec(&ijson::to_value(engine_snapshot)?)?), + } + } + pub fn deserialize(&self, data: &[u8]) -> anyhow::Result { + match self { + Self::Bitcode => Ok(bitcode::deserialize(data)?), + Self::Json => Ok(ijson::from_value(&serde_json::from_slice(data)?)?), + } + } + pub const VALID_STR_ARRAY: [&'static str; 5] = ["Bitcode", "bitcode", "Json", "JSON", "json"]; +} + +impl Default for SerializationMethod { + fn default() -> Self { + Self::Bitcode + } +} + +impl FromStr for SerializationMethod { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s { + "Bitcode" | "bitcode" => Ok(Self::Bitcode), + "Json" | "JSON" | "json" => Ok(Self::Json), + _ => Err("Unknown serialization method"), + } + } +} diff --git a/crates/rnote-engine/src/fileformats/rnoteformat/mod.rs b/crates/rnote-engine/src/fileformats/rnoteformat/mod.rs index 9826a02bc4..535e21610e 100644 --- a/crates/rnote-engine/src/fileformats/rnoteformat/mod.rs +++ b/crates/rnote-engine/src/fileformats/rnoteformat/mod.rs @@ -6,139 +6,236 @@ //! Then [TryFrom] can be implemented to allow conversions and chaining from older to newer versions. // Modules -pub(crate) mod maj0min5patch8; -pub(crate) mod maj0min5patch9; -pub(crate) mod maj0min6; -pub(crate) mod maj0min9; +pub(crate) mod legacy; +pub(crate) mod maj0min12; +pub(crate) mod methods; + +// Re-exports +pub use methods::{CompressionMethod, SerializationMethod}; // Imports -use self::maj0min5patch8::RnoteFileMaj0Min5Patch8; -use self::maj0min5patch9::RnoteFileMaj0Min5Patch9; -use self::maj0min6::RnoteFileMaj0Min6; -use self::maj0min9::RnoteFileMaj0Min9; use super::{FileFormatLoader, FileFormatSaver}; +use crate::engine::{save::SavePrefs, EngineSnapshot}; use anyhow::Context; -use serde::{Deserialize, Serialize}; -use std::io::{Read, Write}; - -/// Compress bytes with gzip. -fn compress_to_gzip(to_compress: &[u8]) -> Result, anyhow::Error> { - let mut encoder = flate2::write::GzEncoder::new(Vec::::new(), flate2::Compression::new(5)); - encoder.write_all(to_compress)?; - Ok(encoder.finish()?) -} +use legacy::LegacyRnoteFile; +use maj0min12::RnoteFileMaj0Min12; -/// Decompress from gzip. -fn decompress_from_gzip(compressed: &[u8]) -> Result, anyhow::Error> { - // Optimization for the gzip format, defined by RFC 1952 - // capacity of the vector defined by the size of the uncompressed data - // given in little endian format, by the last 4 bytes of "compressed" - // - // ISIZE (Input SIZE) - // This contains the size of the original (uncompressed) input data modulo 2^32. - let mut bytes: Vec = { - let mut decompressed_size: [u8; 4] = [0; 4]; - let idx_start = compressed - .len() - .checked_sub(4) - // only happens if the file has less than 4 bytes - .ok_or_else(|| { - anyhow::anyhow!("Invalid file") - .context("Failed to get the size of the decompressed data") - })?; - decompressed_size.copy_from_slice(&compressed[idx_start..]); - // u32 -> usize to avoid issues on 32-bit architectures - // also more reasonable since the uncompressed size is given by 4 bytes - Vec::with_capacity(u32::from_le_bytes(decompressed_size) as usize) - }; - - let mut decoder = flate2::read::MultiGzDecoder::new(compressed); - decoder.read_to_end(&mut bytes)?; - Ok(bytes) -} +pub type RnoteFile = maj0min12::RnoteFileMaj0Min12; +pub type RnoteHeader = maj0min12::RnoteHeaderMaj0Min12; -/// The rnote file wrapper. -/// -/// Used to extract and match the version up front, before deserializing the data. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename = "rnotefile_wrapper")] -struct RnotefileWrapper { - #[serde(rename = "version")] - version: semver::Version, - #[serde(rename = "data")] - data: ijson::IValue, +impl RnoteFileMaj0Min12 { + // ideally, this should never change + pub const MAGIC_NUMBER: [u8; 9] = [0x52, 0x4e, 0x4f, 0x54, 0x45, 0xce, 0xa6, 0xce, 0x9b]; + pub const SEMVER: &'static str = crate::utils::crate_version(); } -/// The Rnote file in the newest format version. -/// -/// This struct exists to allow for upgrading older versions before loading the file in. -pub type RnoteFile = RnoteFileMaj0Min9; +impl FileFormatSaver for RnoteFile { + fn save_as_bytes(&self, _file_name: &str) -> anyhow::Result> { + let version = semver::Version::parse(Self::SEMVER)?; + let pre_release = version.pre.as_str(); + let build_metadata = version.build.as_str(); -impl RnoteFile { - pub const SEMVER: &'static str = crate::utils::crate_version(); + let header = serde_json::to_vec(&ijson::to_value(&self.header)?)?; + let head = [ + &Self::MAGIC_NUMBER[..], + &version.major.to_le_bytes(), + &version.minor.to_le_bytes(), + &version.patch.to_le_bytes(), + &u16::try_from(pre_release.len()) + .context("Prerelease exceeds max size (u16::MAX)")? + .to_le_bytes(), + pre_release.as_bytes(), + &u16::try_from(build_metadata.len()) + .context("BuildMetadata exceeds max size (u16::MAX)")? + .to_le_bytes(), + build_metadata.as_bytes(), + &u32::try_from(header.len()) + .context("Serialized RnoteHeader exceeds max size (u32::MAX)")? + .to_le_bytes(), + &header, + ] + .concat(); + + // .concat is absurdly fast + // much faster than Vec::apend or Vec::extend + Ok([head.as_slice(), self.body.as_slice()].concat()) + } } impl FileFormatLoader for RnoteFile { - fn load_from_bytes(bytes: &[u8]) -> anyhow::Result { - let wrapper = serde_json::from_slice::( - &decompress_from_gzip(bytes).context("decompressing bytes failed.")?, - ) - .context("deserializing RnotefileWrapper from bytes failed.")?; - - // Conversions for older file format versions happen here - if semver::VersionReq::parse(">=0.9.0") - .unwrap() - .matches(&wrapper.version) - { - ijson::from_value::(&wrapper.data) - .context("deserializing RnoteFileMaj0Min9 failed.") - } else if semver::VersionReq::parse(">=0.5.10") - .unwrap() - .matches(&wrapper.version) - { - ijson::from_value::(&wrapper.data) - .context("deserializing RnoteFileMaj0Min6 failed.") - .and_then(RnoteFileMaj0Min9::try_from) - .context("converting RnoteFileMaj0Min6 to newest file version failed.") - } else if semver::VersionReq::parse(">=0.5.9") - .unwrap() - .matches(&wrapper.version) - { - ijson::from_value::(&wrapper.data) - .context("deserializing RnoteFileMaj0Min5Patch9 failed.") - .and_then(RnoteFileMaj0Min6::try_from) - .and_then(RnoteFileMaj0Min9::try_from) - .context("converting RnoteFileMaj0Min5Patch9 to newest file version failed.") - } else if semver::VersionReq::parse(">=0.5.0") + fn load_from_bytes(bytes: &[u8]) -> anyhow::Result + where + Self: Sized, + { + let mut cursor: usize = 0; + + let magic_number = bytes + .get(cursor..9) + .ok_or_else(|| anyhow::anyhow!("Failed to get magic number"))?; + cursor += 9; + + if magic_number != Self::MAGIC_NUMBER { + // Gzip magic number + // The legacy file is generally caught first in Snapshot::load_from_rnote_bytes + // howver this less efficient catch is necessary for rnote-cli mutate + if magic_number[..2] == [0x1f, 0x8b] { + return RnoteFile::try_from(LegacyRnoteFile::load_from_bytes(bytes)?); + } else { + return Err(anyhow::anyhow!("Unrecognized file format")); + } + } + + let mut major: [u8; 8] = [0; 8]; + major.copy_from_slice( + bytes.get(cursor..cursor + 8).ok_or_else(|| { + anyhow::anyhow!("Failed to get version.major, insufficient bytes") + })?, + ); + cursor += 8; + let major = u64::from_le_bytes(major); + + let mut minor: [u8; 8] = [0; 8]; + minor.copy_from_slice( + bytes.get(cursor..cursor + 8).ok_or_else(|| { + anyhow::anyhow!("Failed to get version.minor, insufficient bytes") + })?, + ); + cursor += 8; + let minor = u64::from_le_bytes(minor); + + let mut patch: [u8; 8] = [0; 8]; + patch.copy_from_slice( + bytes.get(cursor..cursor + 8).ok_or_else(|| { + anyhow::anyhow!("Failed to get version.patch, insufficient bytes") + })?, + ); + cursor += 8; + let patch = u64::from_le_bytes(patch); + + let mut pre_release_size: [u8; 2] = [0; 2]; + pre_release_size.copy_from_slice(bytes.get(cursor..cursor + 2).ok_or_else(|| { + anyhow::anyhow!("Failed to get size of version.pre, insufficient bytes") + })?); + cursor += 2; + let pre_release_size = usize::from(u16::from_le_bytes(pre_release_size)); + + let pre_release = if pre_release_size == 0 { + semver::Prerelease::EMPTY + } else { + let text = + core::str::from_utf8(bytes.get(cursor..cursor + pre_release_size).ok_or_else( + || anyhow::anyhow!("Failed to get version.pre, insufficient bytes"), + )?)?; + cursor += pre_release_size; + semver::Prerelease::new(text)? + }; + + let mut build_metadata_size: [u8; 2] = [0; 2]; + build_metadata_size.copy_from_slice(bytes.get(cursor..cursor + 2).ok_or_else(|| { + anyhow::anyhow!("Failed to get size of version.build, insufficient bytes") + })?); + cursor += 2; + let build_metadata_size = usize::from(u16::from_le_bytes(build_metadata_size)); + + let build_metadata = if build_metadata_size == 0 { + semver::BuildMetadata::EMPTY + } else { + let text = + core::str::from_utf8(bytes.get(cursor..cursor + build_metadata_size).ok_or_else( + || anyhow::anyhow!("Failed to get version.build, insufficient bytes"), + )?)?; + cursor += build_metadata_size; + semver::BuildMetadata::new(text)? + }; + + let version = semver::Version { + major, + minor, + patch, + pre: pre_release, + build: build_metadata, + }; + + let mut header_size: [u8; 4] = [0; 4]; + header_size.copy_from_slice( + bytes + .get(cursor..cursor + 4) + .ok_or_else(|| anyhow::anyhow!("Failed to get header size, insufficient bytes"))?, + ); + cursor += 4; + let header_size = usize::try_from(u32::from_le_bytes(header_size)) + .context("Serialized RnoteHeader exceeds max size (usize::MAX)")?; + + let header_slice = bytes + .get(cursor..cursor + header_size) + .ok_or_else(|| anyhow::anyhow!("Failed to get RnoteHeader, insufficient bytes"))?; + cursor += header_size; + + let body_slice = bytes + .get(cursor..) + .ok_or_else(|| anyhow::anyhow!("Failed to get body, insufficient bytes"))?; + + Ok(Self { + header: RnoteHeader::load_from_slice(header_slice, &version)?, + // faster than draining bytes + body: body_slice.to_vec(), + }) + } +} + +impl RnoteHeader { + fn load_from_slice(slice: &[u8], version: &semver::Version) -> anyhow::Result { + if semver::VersionReq::parse(">=0.11.0") .unwrap() - .matches(&wrapper.version) + .matches(version) { - ijson::from_value::(&wrapper.data) - .context("deserializing RnoteFileMaj0Min5Patch8 failed") - .and_then(RnoteFileMaj0Min5Patch9::try_from) - .and_then(RnoteFileMaj0Min6::try_from) - .and_then(RnoteFileMaj0Min9::try_from) - .context("converting RnoteFileMaj0Min5Patch8 to newest file version failed.") + Ok(ijson::from_value(&serde_json::from_slice(slice)?)?) } else { - Err(anyhow::anyhow!( - "failed to load rnote file from bytes, unsupported version: {}.", - wrapper.version - )) + Err(anyhow::anyhow!("Unsupported version: '{}'", version)) } } } -impl FileFormatSaver for RnoteFile { - fn save_as_bytes(&self, _file_name: &str) -> anyhow::Result> { - let wrapper = RnotefileWrapper { - version: semver::Version::parse(Self::SEMVER).unwrap(), - data: ijson::to_value(self).context("converting RnoteFile to JSON value failed.")?, - }; - let compressed = compress_to_gzip( - &serde_json::to_vec(&wrapper).context("Serializing RnoteFileWrapper failed.")?, - ) - .context("compressing bytes failed.")?; +impl TryFrom for EngineSnapshot { + type Error = anyhow::Error; + + fn try_from(value: RnoteFile) -> Result { + let uc_size = usize::try_from(value.header.uc_size).unwrap_or(usize::MAX); + let uc_body = value.header.compression.decompress(uc_size, value.body)?; + let mut engine_snapshot = value.header.serialization.deserialize(&uc_body)?; + + // save preferences are only kept if method_lock is true or both the ser. method and comp. method are "defaults" + let save_prefs = SavePrefs::from(value.header); + if save_prefs.method_lock | save_prefs.conforms_to_default() { + engine_snapshot.save_prefs = save_prefs; + } + + Ok(engine_snapshot) + } +} + +impl TryFrom<&EngineSnapshot> for RnoteFile { + type Error = anyhow::Error; + + fn try_from(value: &EngineSnapshot) -> Result { + let save_prefs = value.save_prefs.clone_config(); + + let compression = save_prefs.compression; + let serialization = save_prefs.serialization; + + let uc_data = serialization.serialize(value)?; + let uc_size = uc_data.len() as u64; + let data = compression.compress(uc_data)?; + let method_lock = save_prefs.method_lock; - Ok(compressed) + Ok(Self { + header: RnoteHeader { + compression, + serialization, + uc_size, + method_lock, + }, + body: data, + }) } } diff --git a/crates/rnote-engine/src/meson.build b/crates/rnote-engine/src/meson.build index eb75fb20d4..b0f7c437b5 100644 --- a/crates/rnote-engine/src/meson.build +++ b/crates/rnote-engine/src/meson.build @@ -7,15 +7,19 @@ rnote_engine_sources = files( 'engine/import.rs', 'engine/mod.rs', 'engine/rendering.rs', + 'engine/save.rs', 'engine/snapshot.rs', 'engine/strokecontent.rs', 'engine/visual_debug.rs', 'fileformats/mod.rs', - 'fileformats/rnoteformat/maj0min5patch8.rs', - 'fileformats/rnoteformat/maj0min5patch9.rs', - 'fileformats/rnoteformat/maj0min6.rs', - 'fileformats/rnoteformat/maj0min9.rs', + 'fileformats/rnoteformat/legacy/mod.rs', + 'fileformats/rnoteformat/legacy/maj0min5patch8.rs', + 'fileformats/rnoteformat/legacy/maj0min5patch9.rs', + 'fileformats/rnoteformat/legacy/maj0min6.rs', + 'fileformats/rnoteformat/legacy/maj0min9.rs', 'fileformats/rnoteformat/mod.rs', + 'fileformats/rnoteformat/methods.rs', + 'fileformats/rnoteformat/maj0min12.rs', 'fileformats/xoppformat.rs', 'pens/brush.rs', 'pens/eraser.rs', diff --git a/crates/rnote-engine/src/utils.rs b/crates/rnote-engine/src/utils.rs index 1f3fa341d2..4ffc5d4d10 100644 --- a/crates/rnote-engine/src/utils.rs +++ b/crates/rnote-engine/src/utils.rs @@ -1,5 +1,7 @@ // Imports use crate::fileformats::xoppformat; +use anyhow::Context; +use futures::{AsyncReadExt, AsyncWriteExt}; use geo::line_string; use p2d::bounding_volume::Aabb; use rnote_compose::Color; @@ -96,3 +98,91 @@ pub mod glib_bytes_base64 { rnote_compose::serialize::sliceu8_base64::deserialize(d).map(glib::Bytes::from_owned) } } + +pub async fn atomic_save_to_file(filepath: Q, bytes: &[u8]) -> anyhow::Result<()> +where + Q: AsRef, +{ + let filepath = filepath.as_ref().to_owned(); + + // checks that the extension is not already 'tmp' + if filepath + .extension() + .ok_or_else(|| anyhow::anyhow!("Specified filepath does not have an extension"))? + .to_str() + .ok_or_else(|| anyhow::anyhow!("The extension of the specified filepath is invalid"))? + == "tmp" + { + Err(anyhow::anyhow!("The extension of the file cannot be 'tmp'"))?; + } + + let tmp_filepath = filepath.with_extension("tmp"); + + let file_write_operation = async { + let mut write_file = async_fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&tmp_filepath) + .await + .with_context(|| { + format!( + "Failed to create/open/truncate tmp file with path '{}'", + tmp_filepath.display() + ) + })?; + write_file.write_all(bytes).await.with_context(|| { + format!( + "Failed to write to tmp file with path '{}'", + tmp_filepath.display() + ) + })?; + write_file.sync_all().await.with_context(|| { + format!( + "Failed to sync tmp file with path '{}'", + tmp_filepath.display() + ) + })?; + + Ok::<(), anyhow::Error>(()) + }; + file_write_operation.await?; + + let file_check_operation = async { + let internal_checksum = crc32fast::hash(bytes); + + let mut read_file = async_fs::OpenOptions::new() + .read(true) + .open(&tmp_filepath) + .await + .with_context(|| { + format!( + "Failed to open/read tmp file with path '{}'", + &tmp_filepath.display() + ) + })?; + let mut data: Vec = Vec::with_capacity(bytes.len()); + read_file.read_to_end(&mut data).await?; + let external_checksum = crc32fast::hash(&data); + + if internal_checksum != external_checksum { + return Err(anyhow::anyhow!( + "Mismatch between the internal and external checksums, temporary file most likely corrupted" + )); + } + + Ok::<(), anyhow::Error>(()) + }; + file_check_operation.await?; + + let file_swap_operation = async { + async_fs::rename(&tmp_filepath, &filepath) + .await + .context("Failed to rename the temporary file into the original one")?; + + Ok::<(), anyhow::Error>(()) + }; + file_swap_operation.await?; + + Ok(()) +} diff --git a/crates/rnote-ui/data/ui/settingspanel.ui b/crates/rnote-ui/data/ui/settingspanel.ui index e1c205a8e7..f4751c2ead 100644 --- a/crates/rnote-ui/data/ui/settingspanel.ui +++ b/crates/rnote-ui/data/ui/settingspanel.ui @@ -383,17 +383,33 @@ gets disabled. - + Invert Color Brightness Invert the brightness of all background pattern colors - + center Invert + + + Compression Level + + + + Very High + High + Medium + Low + Very Low + + + + + diff --git a/crates/rnote-ui/src/canvas/imexport.rs b/crates/rnote-ui/src/canvas/imexport.rs index 8a8b0da871..fb5c1e98f2 100644 --- a/crates/rnote-ui/src/canvas/imexport.rs +++ b/crates/rnote-ui/src/canvas/imexport.rs @@ -1,8 +1,6 @@ // Imports use super::RnCanvas; -use anyhow::Context; use futures::channel::oneshot; -use futures::AsyncWriteExt; use gtk4::{gio, prelude::*}; use rnote_compose::ext::Vector2Ext; use rnote_engine::engine::export::{DocExportPrefs, DocPagesExportPrefs, SelectionExportPrefs}; @@ -211,7 +209,7 @@ impl RnCanvas { self.set_save_in_progress(true); debug!("Saving file is now in progress"); - let file_path = file + let filepath = file .path() .ok_or_else(|| anyhow::anyhow!("Could not get a path for file: `{file:?}`."))?; let basename = file @@ -220,43 +218,11 @@ impl RnCanvas { let rnote_bytes_receiver = self .engine_ref() .save_as_rnote_bytes(basename.to_string_lossy().to_string()); - let mut skip_set_output_file = false; - if let Some(output_file_path) = self.output_file().and_then(|f| f.path()) { - if crate::utils::paths_abs_eq(output_file_path, &file_path).unwrap_or(false) { - skip_set_output_file = true; - } - } self.dismiss_output_file_modified_toast(); - let file_write_operation = async move { - let bytes = rnote_bytes_receiver.await??; - self.set_output_file_expect_write(true); - let mut write_file = async_fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&file_path) - .await - .context(format!( - "Failed to create/open/truncate file for path '{}'", - file_path.display() - ))?; - if !skip_set_output_file { - // this installs the file watcher. - self.set_output_file(Some(file.to_owned())); - } - write_file.write_all(&bytes).await.context(format!( - "Failed to write bytes to file with path '{}'", - file_path.display() - ))?; - write_file.sync_all().await.context(format!( - "Failed to sync file after writing with path '{}'", - file_path.display() - ))?; - Ok(()) - }; - - if let Err(e) = file_write_operation.await { + let bytes = rnote_bytes_receiver.await??; + self.set_output_file_expect_write(true); + if let Err(e) = rnote_engine::utils::atomic_save_to_file(&filepath, &bytes).await { self.set_save_in_progress(false); // If the file operations failed in any way, we make sure to clear the expect_write flag // because we can't know for sure if the output-file watcher will be able to. @@ -264,6 +230,7 @@ impl RnCanvas { return Err(e); } + self.set_output_file(Some(gio::File::for_path(&filepath))); debug!("Saving file has finished successfully"); self.set_unsaved_changes(false); self.set_save_in_progress(false); diff --git a/crates/rnote-ui/src/settingspanel/mod.rs b/crates/rnote-ui/src/settingspanel/mod.rs index 282be62719..0753d1ab99 100644 --- a/crates/rnote-ui/src/settingspanel/mod.rs +++ b/crates/rnote-ui/src/settingspanel/mod.rs @@ -5,6 +5,7 @@ mod penshortcutrow; // Re-exports pub(crate) use penshortcutrow::RnPenShortcutRow; use rnote_compose::ext::Vector2Ext; +use rnote_engine::fileformats::rnoteformat::CompressionMethod; // Imports use crate::{RnAppWindow, RnCanvasWrapper, RnIconPicker, RnUnitEntry}; @@ -19,6 +20,7 @@ use rnote_compose::penevent::ShortcutKey; use rnote_engine::document::background::PatternStyle; use rnote_engine::document::format::{self, Format, PredefinedFormat}; use rnote_engine::document::Layout; +use rnote_engine::engine::CompressionLevel; use rnote_engine::ext::GdkRGBAExt; use std::cell::RefCell; @@ -94,7 +96,9 @@ mod imp { #[template_child] pub(crate) doc_background_pattern_height_unitentry: TemplateChild, #[template_child] - pub(crate) background_pattern_invert_color_button: TemplateChild