From 3f9545c9620768521af1c326382aa36d71ed1a17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 00:51:35 -0800 Subject: [PATCH] Native files: documents from before 0.21.0 keep their Twist turning as saved #5139 (in 0.21.0) made a positive Twist angle turn clockwise, but documents saved by earlier releases stored their angles with the other sign and reopened twisted the other way. The document version (FORMAT_VERSION) goes from 1 to 2. Opening a version 1 document negates its Twist angles (in layers, opacity-mask art, Shaper edits, symbols, graph designs, pattern swatches, graphic styles and brush definitions), unless the file is of the current file version and its generator is VectorCraft 0.21.0 or later. The document opens and saves as version 2. A save for an older file version, which only apps before 0.21.0 read, writes a version 1 document with the angles negated back. Graphic style libraries (.vcstyles) go to version 2: a version 1 library's Twist angles are negated when it is read. Readers do not check either version, so older builds open the new files as before. --- crates/doc/src/lib.rs | 1 + crates/doc/src/limits.rs | 4 +- crates/doc/src/style_libs.rs | 12 +- crates/doc/src/twist.rs | 231 ++++++++++++++++++++++++++ crates/doc/src/twist/tests.rs | 186 +++++++++++++++++++++ crates/engine/src/tests/nativefile.rs | 34 ++++ crates/format/src/lib.rs | 112 ++++++++++++- 7 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 crates/doc/src/twist.rs create mode 100644 crates/doc/src/twist/tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 49ba5465a..84a66220f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -68,6 +68,7 @@ mod symbols; pub use symbols::SymbolBoxes; pub mod text; pub mod trace; +pub mod twist; pub mod variables; pub mod xml_id; diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs index 027148ade..86c2e802a 100644 --- a/crates/doc/src/limits.rs +++ b/crates/doc/src/limits.rs @@ -13,7 +13,9 @@ pub enum DocError { Invalid(String), } -pub const FORMAT_VERSION: u32 = 1; +/// The document version new documents get. Version 2: a positive Twist angle turns clockwise +/// ([`crate::twist`]). +pub const FORMAT_VERSION: u32 = 2; /// Node, slice and asset ids are handed out below this: no document gets near it by counting, so /// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 30aae49e1..6eda3acef 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -98,6 +98,10 @@ impl GraphicStyle { /// The extension (and `format` header) of graphic style library files. pub const STYLES_EXT: &str = "vcstyles"; +/// The version of the `.vcstyles` files [`write`] writes. Version 2: a positive Twist angle turns +/// clockwise ([`crate::twist`]). +const STYLES_VERSION: u32 = crate::twist::TWIST_CLOCKWISE_SINCE; + /// The file: a header around the library. #[derive(Serialize, Deserialize)] struct NativeFile { @@ -109,17 +113,21 @@ struct NativeFile { /// `lib` as a `.vcstyles` file. pub fn write(lib: &StyleLibrary) -> String { - let file = NativeFile { format: STYLES_EXT.into(), version: 1, library: lib.clone() }; + let file = NativeFile { format: STYLES_EXT.into(), version: STYLES_VERSION, library: lib.clone() }; serde_json::to_string_pretty(&file).unwrap_or_default() } -/// Read a `.vcstyles` file; an unnamed library is called `name`. +/// Read a `.vcstyles` file; an unnamed library is called `name`. A version 1 file was written by +/// an app that turned Twist counterclockwise: its Twist angles are negated. pub fn read(text: &str, name: &str) -> Result { let f: NativeFile = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("not a graphic style library: {e}"))?; if f.format != STYLES_EXT { return Err(format!("not a graphic style library (format `{}`)", f.format)); } let mut lib = f.library; + if f.version < STYLES_VERSION { + crate::twist::negate_library(&mut lib); + } if lib.name.trim().is_empty() { lib.name = name.into(); } diff --git a/crates/doc/src/twist.rs b/crates/doc/src/twist.rs new file mode 100644 index 000000000..8865fd2a9 --- /dev/null +++ b/crates/doc/src/twist.rs @@ -0,0 +1,231 @@ +//! The Twist effect's angle in saved documents. A positive angle turns clockwise on the page in +//! VectorCraft 0.21.0 and later; earlier releases turned it counterclockwise. Documents of version +//! 2 ([`TWIST_CLOCKWISE_SINCE`]) turn clockwise. Version 1 documents written before 0.21.0 have +//! their Twist angles negated when they open, so they draw as they were saved +//! ([`Document::migrate_twist_angles`], [`written_clockwise`]), and a save for an earlier app +//! negates them back ([`Document::store_legacy_twist_angles`]). Graphic style library files +//! (`.vcstyles`) of version 1 have their Twist angles negated when they are read +//! ([`crate::style_libs::read`]); [`crate::style_libs::write`] writes version 2. + +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use crate::style_libs::StyleLibrary; +use crate::{Appearance, Document, Effect, Node}; + +/// The Twist effect's id. +pub const TWIST: &str = "distort.twist"; + +/// The first document version whose Twist angles turn clockwise for a positive value. +pub const TWIST_CLOCKWISE_SINCE: u32 = 2; + +/// The first release (major, minor) whose Twist turned clockwise for a positive angle. +const CLOCKWISE_RELEASE: (u64, u64) = (0, 21); + +/// Did the app named by a native file's `generator` (`"VectorCraft 0.21.0"`) turn Twist effects +/// clockwise for a positive angle? It did from 0.21.0. Any other value, or none, is an earlier +/// app (DrawCraft included). +pub fn written_clockwise(generator: Option<&str>) -> bool { + let Some(version) = generator.and_then(|g| g.strip_prefix("VectorCraft ")) else { return false }; + let mut parts = version.split('.').map(|p| p.parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(major), Some(minor)) => (major, minor) >= CLOCKWISE_RELEASE, + _ => false, + } +} + +/// The angle a Twist effect without a readable one draws with (`vectorcraft-effects` reads a +/// missing or unreadable `angle` as this). +const DEFAULT_ANGLE: f64 = 10.0; + +/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s +/// `DOC_KEY`), whose art can carry effects. +const BRUSHES_KEY: &str = "brushes"; + +/// The angle a Twist effect with `params` draws with, read as `vectorcraft-effects` reads it: a +/// number, the number a string starts with, or a boolean as 1 or 0; anything else (or a value +/// that is not finite) is [`DEFAULT_ANGLE`]. +fn angle(params: &Value) -> f64 { + let v = match params.get("angle") { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => { + let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect(); + t.parse().ok() + } + Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + }; + v.filter(|v| v.is_finite()).unwrap_or(DEFAULT_ANGLE) +} + +/// Write the negated angle a Twist effect with `params` draws with into `params`, as a number. +fn negate_params(params: &mut Value) { + // `0.0 - a` rather than `-a`: an angle of 0 stays 0, not -0. + let turned = Value::from(0.0 - angle(params)); + match params { + Value::Object(m) => { + m.insert("angle".into(), turned); + } + p => *p = json!({ "angle": turned }), + } +} + +fn has_twist(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.id == TWIST) +} + +fn appearance_has_twist(a: &Appearance) -> bool { + has_twist(&a.effects) || a.items.iter().any(|i| has_twist(i.effects())) +} + +fn negate_appearance(a: &mut Appearance) { + for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + if e.id == TWIST { + negate_params(&mut e.params); + } + } +} + +/// The appearances a Shaper Group's face and edge edits paint with. +fn shaper_appearances(n: &Node) -> impl Iterator { + let s = n.extras().shaper.as_deref(); + let faces = s.into_iter().flat_map(|s| s.faces.iter().filter_map(|f| f.paint.as_ref())); + let edges = s.into_iter().flat_map(|s| s.edges.iter().filter_map(|e| e.paint.as_ref())); + faces.chain(edges) +} + +/// Does `n`'s subtree (opacity-mask art and Shaper edits included) hold a Twist effect? +fn node_has_twist(n: &Node) -> bool { + appearance_has_twist(&n.appearance) + || n.extras().mask.as_ref().is_some_and(|m| node_has_twist(&m.art)) + || shaper_appearances(n).any(appearance_has_twist) + || n.children().is_some_and(|c| c.iter().any(|c| node_has_twist(c))) +} + +/// Negate the Twist angles of `a`'s subtree, copying only the nodes on the way to them. +fn negate_node(a: &mut Arc) { + if !node_has_twist(a) { + return; + } + let n = Arc::make_mut(a); + if appearance_has_twist(&n.appearance) { + negate_appearance(&mut n.appearance); + } + if let Some(x) = n.extras_if_any() { + if let Some(m) = x.mask.as_mut() { + negate_node(&mut m.art); + } + if let Some(s) = x.shaper.as_mut() { + let faces = s.faces.iter_mut().filter_map(|f| f.paint.as_mut()); + for p in faces.chain(s.edges.iter_mut().filter_map(|e| e.paint.as_mut())) { + negate_appearance(p); + } + } + } + for c in n.children_mut().into_iter().flatten() { + negate_node(c); + } +} + +fn is_twist_json(m: &Map) -> bool { + m.get("id").and_then(Value::as_str) == Some(TWIST) +} + +/// Does JSON `v` (brush definitions) hold a Twist effect? +fn json_has_twist(v: &Value) -> bool { + match v { + Value::Object(m) => is_twist_json(m) || m.values().any(json_has_twist), + Value::Array(a) => a.iter().any(json_has_twist), + _ => false, + } +} + +/// Negate the Twist angles in JSON `v`: every object whose `id` is [`TWIST`] is a serialized +/// [`Effect`] (object ids are numbers). +fn negate_json(v: &mut Value) { + match v { + Value::Object(m) if is_twist_json(m) => negate_params(m.entry("params").or_insert(Value::Null)), + Value::Object(m) => m.values_mut().for_each(negate_json), + Value::Array(a) => a.iter_mut().for_each(negate_json), + _ => {} + } +} + +/// Negate every Twist angle of a graphic style library: its styles' and its patterns' art. +pub(crate) fn negate_library(lib: &mut StyleLibrary) { + for g in &mut lib.styles { + negate_appearance(&mut g.appearance); + } + for p in &mut lib.patterns { + p.art.iter_mut().for_each(negate_node); + } +} + +impl Document { + /// A document before version [`TWIST_CLOCKWISE_SINCE`] that holds a Twist effect: when + /// `written_clockwise` (asked only then; see [`written_clockwise`]) says the app that saved it + /// turned Twist counterclockwise, negate its Twist angles, so it draws as it was saved. A later + /// version is left as it is, and the document's version is not changed. It recurses through + /// the art, once per level of nesting. + pub fn migrate_twist_angles(&mut self, written_clockwise: impl FnOnce() -> bool) { + if self.version >= TWIST_CLOCKWISE_SINCE || !self.has_twist() || written_clockwise() { + return; + } + self.negate_twist_angles(); + } + + /// The reverse of [`Document::migrate_twist_angles`], for saving for an app that turned Twist + /// counterclockwise: negate the Twist angles and mark the document version 1. Call it on a + /// document whose angles turn clockwise, as every opened or new document's do, whatever + /// version another conversion for that save has marked it already. + pub fn store_legacy_twist_angles(&mut self) { + self.version = TWIST_CLOCKWISE_SINCE - 1; + if self.has_twist() { + self.negate_twist_angles(); + } + } + + /// Does any Twist effect sit where [`Document::negate_twist_angles`] negates them? + fn has_twist(&self) -> bool { + let mut roots = self + .layers + .iter() + .chain(self.symbols.iter().map(|s| &s.art)) + .chain(self.graph_designs.iter().map(|g| &g.art)) + .chain(self.patterns.iter().flat_map(|p| p.art.iter())) + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + roots.any(|r| node_has_twist(r)) + || self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) + || self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist) + } + + /// Negate every Twist angle: in the art of the layers, symbols, graph designs, pattern + /// swatches and the pattern definition that pattern editing's Cancel puts back (opacity-mask + /// art and Shaper edits included), in the graphic styles and in the brush definitions. + fn negate_twist_angles(&mut self) { + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + negate_node(root); + } + if self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) { + for g in self.graphic_styles.iter_mut() { + negate_appearance(&mut g.appearance); + } + } + if self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist) + && let Some(b) = self.unknown.get_mut(BRUSHES_KEY) + { + negate_json(b); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/doc/src/twist/tests.rs b/crates/doc/src/twist/tests.rs new file mode 100644 index 000000000..699be66cb --- /dev/null +++ b/crates/doc/src/twist/tests.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::*; +use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec}; +use crate::{GraphicStyle, OpacityMask, PatternDef, Symbol}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// An appearance with a Twist of `params` on the object and on its fill, and a Roughen. +fn twisted_appearance(params: &Value) -> Appearance { + let mut a = Appearance::default_art(); + a.effects.push(fx(TWIST, params.clone())); + a.effects.push(fx("distort.roughen", json!({"size": 5}))); + a.items[0].effects_mut().push(fx(TWIST, params.clone())); + a +} + +fn twisted(d: &mut Document, params: &Value) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), twisted_appearance(params)) +} + +/// A version 1 document with Twist effects of `params` everywhere one is stored: on a path, in a +/// group, in an opacity mask's art, in a Shaper edit, in a symbol, a pattern swatch and a graphic +/// style, and `brush` in a brush definition. Its first layer holds the art; its last has no +/// Twist. +fn document(params: &Value, brush: &Value) -> Document { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let path = twisted(&mut d, params); + d.insert(Some(l), 0, path).unwrap(); + let inner = twisted(&mut d, params); + let group = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.insert(Some(l), 1, group).unwrap(); + let mask_art = twisted(&mut d, params); + let mut masked = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art()); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, true))); + d.insert(Some(l), 2, masked).unwrap(); + let mut shaper = Node::group(d.alloc_id(), vec![]); + let face = FaceEdit { + selector: FaceSelector { sources: vec![0], anchor: Point::ZERO }, + erase: false, + erase_edges: false, + paint_source: None, + merge: None, + paint: Some(twisted_appearance(params)), + }; + shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] })); + d.insert(Some(l), 3, shaper).unwrap(); + let art = twisted(&mut d, params); + d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art), dynamic: false }); + let art = twisted(&mut d, params); + d.patterns.push(PatternDef::new("P", vec![Arc::new(art)])); + d.graphic_styles.push(GraphicStyle::new("G", twisted_appearance(params))); + d.unknown.insert(BRUSHES_KEY.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [brush]}}}])); + d.add_layer(None); + d.version = 1; + d +} + +/// The angle each Twist effect of the saved document draws with, in the order the JSON lists them. +fn angles(d: &Document) -> Vec { + fn walk(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => { + if is_twist_json(m) { + out.push(angle(m.get("params").unwrap_or(&Value::Null))); + } + m.values().for_each(|c| walk(c, out)); + } + Value::Array(a) => a.iter().for_each(|c| walk(c, out)), + _ => {} + } + } + let mut out = vec![]; + walk(&serde_json::to_value(d).unwrap(), &mut out); + out +} + +#[test] +fn a_version_1_document_from_before_0_21_has_its_twist_angles_negated() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST})); + let before = angles(&d); + // Two per path, shaper edit and style; one in the brush. + assert_eq!(before.len(), 15, "{before:?}"); + assert_eq!(before.iter().filter(|&&a| a == DEFAULT_ANGLE).count(), 1, "{before:?}"); + d.migrate_twist_angles(|| false); + assert_eq!(d.version, 1, "the caller sets the version"); + assert_eq!(angles(&d), before.iter().map(|a| -a).collect::>()); + let roughen = serde_json::to_string(&d).unwrap().matches(r#""id":"distort.roughen","params":{"size":5}"#).count(); + assert_eq!(roughen, 7, "other effects are left alone"); +} + +#[test] +fn a_document_that_turned_clockwise_is_left_as_it_is() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST, "params": {"angle": 30}})); + let before = d.clone(); + d.migrate_twist_angles(|| true); + assert_eq!(d, before, "version 1, written by 0.21.0 or later"); + d.version = 2; + let before = d.clone(); + d.migrate_twist_angles(|| panic!("a version 2 document's writer is not asked about")); + assert_eq!(d, before, "version 2"); +} + +#[test] +fn the_writer_is_asked_about_only_when_a_twist_is_stored() { + let mut d = Document::new(10.0, 10.0); + d.version = 1; + let before = d.clone(); + d.migrate_twist_angles(|| panic!("a document without a Twist needs no answer")); + assert_eq!(d, before); +} + +#[test] +fn angles_are_read_as_the_effect_reads_them() { + let read = |params: Value| { + let mut e = fx(TWIST, params); + negate_params(&mut e.params); + e.params + }; + assert_eq!(read(json!({"angle": 30, "x": 1})), json!({"angle": -30.0, "x": 1})); + assert_eq!(read(json!({"angle": "45 deg"})), json!({"angle": -45.0})); + assert_eq!(read(json!({"angle": true})), json!({"angle": -1.0})); + assert_eq!(read(json!({})), json!({"angle": -10.0})); + assert_eq!(read(Value::Null), json!({"angle": -10.0})); + let zero = read(json!({"angle": 0})); + assert!(zero["angle"].as_f64().is_some_and(|a| a == 0.0 && a.is_sign_positive()), "{zero}"); +} + +#[test] +fn only_the_art_holding_a_twist_is_copied() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST})); + let plain = d.layers.last().cloned().unwrap(); + let twisted = d.layers[0].clone(); + d.migrate_twist_angles(|| false); + assert!(Arc::ptr_eq(d.layers.last().unwrap(), &plain), "a layer without a Twist is not copied"); + assert!(!Arc::ptr_eq(&d.layers[0], &twisted)); +} + +#[test] +fn storing_for_an_earlier_app_and_reading_back_round_trips() { + let original = document(&json!({"angle": 30.0}), &json!({"id": TWIST, "params": {"angle": -12.5}})); + let mut d = original.clone(); + d.migrate_twist_angles(|| false); + d.version = 2; + let current = d.clone(); + assert_eq!(angles(¤t), angles(&original).iter().map(|a| -a).collect::>()); + d.store_legacy_twist_angles(); + assert_eq!(d, original); + d.migrate_twist_angles(|| false); + d.version = 2; + assert_eq!(d, current); + // Another conversion for the same save may have marked the document version 1 already. + d.version = 1; + d.store_legacy_twist_angles(); + assert_eq!(d, original); +} + +#[test] +fn a_version_1_style_library_has_its_twist_angles_negated() { + let lib = StyleLibrary { name: "L".into(), styles: vec![GraphicStyle::new("G", twisted_appearance(&json!({"angle": 30.0})))], patterns: vec![] }; + let text = crate::style_libs::write(&lib); + assert!(text.contains(r#""version": 2"#), "{text}"); + assert_eq!(crate::style_libs::read(&text, "x").unwrap(), lib, "a version 2 file reads as written"); + let old = crate::style_libs::read(&text.replacen(r#""version": 2"#, r#""version": 1"#, 1), "x").unwrap(); + let fx = &old.styles[0].appearance.effects; + assert_eq!(fx[0].params, json!({"angle": -30.0})); + assert_eq!(fx[1], lib.styles[0].appearance.effects[1]); + assert_eq!(old.styles[0].appearance.items[0].effects()[0].params, json!({"angle": -30.0})); +} + +#[test] +fn releases_from_0_21_turned_twist_clockwise() { + for g in ["VectorCraft 0.21.0", "VectorCraft 0.21.4", "VectorCraft 0.22.0-dev", "VectorCraft 1.0.0"] { + assert!(written_clockwise(Some(g)), "{g}"); + } + for g in ["VectorCraft 0.20.0", "VectorCraft 0.9.12", "DrawCraft 0.1.0", "VectorCraft", "VectorCraft x.y", "vectorcraft 0.21.0"] { + assert!(!written_clockwise(Some(g)), "{g}"); + } + assert!(!written_clockwise(None)); +} diff --git a/crates/engine/src/tests/nativefile.rs b/crates/engine/src/tests/nativefile.rs index 4f67890b1..5b9973e56 100644 --- a/crates/engine/src/tests/nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -186,6 +186,40 @@ fn newer_keys_and_legacy_profiles_survive_open_and_save() { assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1"); } +/// A version 1 document saved before VectorCraft 0.21.0 turned its Twist effects counterclockwise +/// for a positive angle. It opens with the angles negated, so its art turns as it did; saved +/// again it is a version 2 document, and saved for an older file version it is version 1 again. +#[test] +fn a_version_1_documents_twist_turns_as_it_was_saved() { + use vectorcraft_doc::{Appearance, Document, Effect, Node}; + use vectorcraft_geom::{PathData, Point, Rect, SubPath}; + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let right = PathData::single(SubPath::polyline(&[Point::new(55.0, 50.0), Point::new(56.0, 50.0)], false)); + let mut a = Appearance::default_art(); + a.effects.push(Effect { id: "distort.twist".into(), params: json!({"angle": 45}), visible: true }); + d.insert(Some(l), 0, Node::path(id, right.clone(), a)).unwrap(); + let mut j = json_of(&vectorcraft_format::save(&d, false)); + j["document"]["version"] = json!(1); + j["generator"] = json!("VectorCraft 0.20.0"); + let mut s = Session::new(); + open(&mut s, "old.vectorcraft", &serde_json::to_vec(&j).unwrap()); + let doc = &s.doc().unwrap().doc; + let n = doc.layers[0].children().and_then(|c| c.first()).cloned().unwrap(); + let turned = vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &right, Rect::new(0.0, 0.0, 100.0, 100.0)); + // Counterclockwise on the page takes a point right of the center up (y runs down). + let p = turned.subpaths[0].anchors[0].p; + assert!(p.y < 49.5, "{p:?}"); + let again = json_of(&saved(&mut s, json!({}))); + assert_eq!(again["document"]["version"], 2); + assert!(again.to_string().contains(r#""angle":-45.0"#), "{again}"); + // Saved for an older file version, it is version 1 with its angle as 0.20.0 saved it. + let older = json_of(&saved(&mut s, json!({"version": 3}))); + assert_eq!(older["document"]["version"], 1); + assert!(older.to_string().contains(r#""angle":45.0"#), "{older}"); +} + /// A save records the date the document was made in, so the same commands twice gave different /// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and /// `SOURCE_DATE_EPOCH` for an export that takes no date param. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 57a5cfc95..6ed3e6840 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -29,6 +29,13 @@ //! `data`. Every version loads, and [`save_with`] writes any of them for older apps (under the //! format name `drawcraft`, which every version reads). Readers reject files whose `version` is //! newer than they support. +//! +//! The document has a version of its own (`document.version`, [`vectorcraft_doc::FORMAT_VERSION`]), +//! which readers do not check. A version 2 document's Twist effects turn clockwise for a positive +//! angle. A version 1 document's Twist angles are negated when it opens, unless the file is of +//! [`VERSION`] and its `generator` is VectorCraft 0.21.0 or later, which turned them clockwise +//! already; [`save_with`] writes a version 1 document with them negated back for an older file +//! version ([`vectorcraft_doc::twist`]). #![forbid(unsafe_code)] #[cfg(not(target_arch = "wasm32"))] @@ -244,6 +251,10 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { if version < 3 { d.store_legacy_color_profiles(); } + // Apps that read only older file versions predate 0.21.0 and turn Twist the other way. + if version < VERSION { + d.store_legacy_twist_angles(); + } let v1 = if version == 1 { let mut v = serde_json::to_value(&d).map_err(encode_err)?; legacy::anchors_to_v1(&mut v); @@ -489,7 +500,7 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result vectorcraft_doc::node::with_buffered_value_budget(max_values, || Contents::::read(file.json())) }) }; - let f = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| match e { + let mut f = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| match e { ReadError::Json(e) => { let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() }; FormatError::NotVectorcraft(why) @@ -498,6 +509,12 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result e => FormatError::NotVectorcraft(e.to_string()), })?; let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }; + // A version 1 document saved before VectorCraft 0.21.0, or for an app before it (an older file + // version): its Twist angles turned the other way. The app that wrote it is read from the JSON + // before the data after it is taken; the walk's depth is bounded by the JSON reader's nesting + // limit. + let current_file = f.version >= VERSION; + f.document.migrate_twist_angles(|| current_file && vectorcraft_doc::twist::written_clockwise(generator(file.json()).as_deref())); // The bytes after the JSON, read in one pass: the images' first, then the profiles'. let entries: Vec<[u64; 2]> = f.images.values().chain(f.profiles.values()).filter_map(|i| i.blob).collect(); let tail = file.entries(&entries)?; @@ -559,6 +576,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result // labels and the character styles, within the ranges `document.setup` takes (after the depth // limit: the walk recurses). doc.keep_scripts_in_range(); + // The conversions above bring an older document to today's version. + doc.version = doc.version.max(vectorcraft_doc::FORMAT_VERSION); let profiles = f .profiles .into_iter() @@ -574,6 +593,37 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result Ok(NativeFile { doc, depth_notes, info, profiles }) } +/// The file's first `generator` member, the app that wrote it (`None`: it has none, it is not a +/// string, or the file is not an object of members). The other members, and any further +/// `generator`, are skipped without being kept, as reading the file skips them. +fn generator(json: &[u8]) -> Option { + struct Generator(Option); + struct Members; + impl<'de> serde::de::Visitor<'de> for Members { + type Value = Generator; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a file's members") + } + fn visit_map>(self, mut m: A) -> Result { + let mut first: Option = None; + while let Some(key) = m.next_key::>()? { + if key == "generator" && first.is_none() { + first = Some(m.next_value()?); + } else { + m.next_value::()?; + } + } + Ok(Generator(first.and_then(|v| v.as_str().map(str::to_owned)))) + } + } + impl<'de> serde::Deserialize<'de> for Generator { + fn deserialize>(d: D) -> Result { + d.deserialize_map(Members) + } + } + serde_json::from_slice::(json).ok()?.0 +} + /// The bytes of the member of the file's head `pick` picks (only that entry of the data after /// the JSON is kept). fn head_member(bytes: &[u8], pick: impl FnOnce(Head) -> Option) -> Option> { @@ -853,6 +903,66 @@ mod tests { assert!(why.contains("more than 100 values before its type"), "{why}"); } + /// A version 1 document written before VectorCraft 0.21.0 (or by an app the file does not + /// name) has its Twist angles negated when it opens; one written by 0.21.0 or later, and a + /// version 2 document, keep them. Each opens as version 2. A save for an older file version + /// negates them back. + #[test] + fn twist_angles_follow_the_document_version_and_generator() { + use vectorcraft_doc::Effect; + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut a = Appearance::default_art(); + a.effects.push(Effect { id: "distort.twist".into(), params: serde_json::json!({"angle": 30.0}), visible: true }); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), a)).unwrap(); + let angle = |d: &Document| d.node(id).and_then(|n| n.appearance.effects[0].params["angle"].as_f64()); + let current = save(&d, false); + let back = load(¤t).unwrap(); + assert_eq!((back.version, angle(&back)), (2, Some(30.0)), "a version 2 document is read as saved"); + let opened = |generator: Option<&str>| { + let mut v: serde_json::Value = serde_json::from_slice(¤t).unwrap(); + v["document"]["version"] = 1.into(); + match generator { + Some(g) => v["generator"] = g.into(), + None => { + v.as_object_mut().unwrap().remove("generator"); + } + } + let d = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + (d.version, angle(&d)) + }; + for g in [Some("VectorCraft 0.20.0"), Some("VectorCraft 0.9.12"), Some("DrawCraft 0.1.0"), Some("VectorCraft"), None] { + assert_eq!(opened(g), (2, Some(-30.0)), "{g:?}"); + } + for g in ["VectorCraft 0.21.0", "VectorCraft 0.21.3", "VectorCraft 1.0.0"] { + assert_eq!(opened(Some(g)), (2, Some(30.0)), "{g}"); + } + // A file may repeat `generator`: the first one names the app. + let mut v: serde_json::Value = serde_json::from_slice(¤t).unwrap(); + v["document"]["version"] = 1.into(); + v["generator"] = "VectorCraft 0.21.0".into(); + let text = serde_json::to_string(&v).unwrap(); + let first = r#""generator":"VectorCraft 0.21.0""#; + let twice = text.replacen(first, &format!(r#"{first},"generator":"DrawCraft 0.1.0""#), 1); + assert_ne!(twice, text); + assert_eq!(angle(&load(twice.as_bytes()).unwrap()), Some(30.0)); + v["generator"] = "VectorCraft 0.20.0".into(); + let old = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let again = load(&save(&old, false)).unwrap(); + assert_eq!((again.version, angle(&again)), (2, Some(-30.0)), "saved again, it is version 2"); + // Saved for an older file version (apps before 0.21.0): version 1 with the angle negated + // back, whatever the generator, and it reopens as it was. + for version in MIN_VERSION..VERSION { + let bytes = save_with(&d, &SaveOptions { version, ..SaveOptions::default() }).unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["document"]["version"], 1, "file version {version}"); + assert!(String::from_utf8_lossy(&bytes).contains(r#""angle":-30.0"#), "file version {version}"); + let back = load(&bytes).unwrap(); + assert_eq!((back.version, angle(&back)), (2, Some(30.0)), "file version {version}"); + } + } + #[test] fn rejects_foreign_and_future() { assert!(load(b"{}").is_err());