diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index c399bf204..6656e75ae 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -430,7 +430,7 @@ fn art_id() -> crate::NodeId { } /// Visit `n` and every descendant (opacity-mask art included) mutably. -fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { +pub(crate) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { f(n); if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { each_mut(Arc::make_mut(&mut m.art), f); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index e1ed1cd3b..b13f372dd 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -179,6 +179,18 @@ pub struct ScriptMetrics { impl ScriptMetrics { pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 }; + /// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to + /// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside + /// its range becomes the nearer end. + pub fn in_range(self) -> Self { + Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) } + } +} + +/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for +/// `smallCapsSize`. A size outside it becomes the nearer end. +fn small_caps_in_range(size: f64) -> f64 { + size.clamp(1.0, 200.0) } impl Default for ScriptMetrics { @@ -215,6 +227,14 @@ impl CharPosition { CharPosition::Subscript(_) => "subscript", } } + /// Returns this position with its proportions kept by [`ScriptMetrics::in_range`]. + pub fn in_range(self) -> Self { + match self { + CharPosition::Normal => CharPosition::Normal, + CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()), + CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()), + } + } } fn ten() -> f64 { @@ -333,6 +353,60 @@ impl CharStyle { pub fn basic_appearance(&self) -> Appearance { Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } } + /// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and + /// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`. + pub fn keep_scripts_in_range(&mut self) { + self.position = self.position.in_range(); + self.small_caps = self.small_caps.map(small_caps_in_range); + } +} + +impl crate::Document { + /// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup` + /// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph + /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts + /// back (opacity-mask art included), in graphs' label styles and in the character styles. It + /// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`]. + pub fn keep_scripts_in_range(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + r.style.keep_scripts_in_range(); + } + } + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + let l = &mut g.label_styles; + for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { + st.keep_scripts_in_range(); + } + } + }; + 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 { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + // Character styles store the attributes they set by their serialized names. + for def in self.char_styles.iter_mut() { + if let Some(v) = def.attrs.get_mut("position") + && let Ok(p) = serde_json::from_value::(v.clone()) + && p.in_range() != p + { + *v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null); + } + if let Some(v) = def.attrs.get_mut("small_caps") + && let Some(size) = v.as_f64() + && small_caps_in_range(size) != size + { + *v = small_caps_in_range(size).into(); + } + } + } } /// Tab stop alignment (Tabs panel). diff --git a/crates/engine/src/tests_docsetup.rs b/crates/engine/src/tests_docsetup.rs index 64d279275..4ec581e8d 100644 --- a/crates/engine/src/tests_docsetup.rs +++ b/crates/engine/src/tests_docsetup.rs @@ -199,6 +199,81 @@ fn character_styles_follow_the_setup_sizes() { assert_eq!(def.attrs["small_caps"], json!(80.0)); } +/// A file whose type, symbols, graph label styles, character styles or pattern definition saved in +/// pattern editing mode hold superscript, subscript or small caps proportions outside the ranges +/// `document.setup` takes opens with each at the nearer end of its range; the other character +/// attributes open as saved. +#[test] +fn a_file_with_script_proportions_out_of_range_opens_with_them_in_range() { + let mut s = session(); + let id = create_text(&mut s, "E=mc2"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap(); + let water = create_text(&mut s, "H2O"); + s.execute("text.setRangeStyle", &json!({"id": water.0, "start": 1, "end": 2, "position": "subscript"})).unwrap(); + let csv = ",2024\nQ1,3\nQ2,5"; + let graph = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 150, "width": 200, "height": 100, "csv": csv})).unwrap(); + let graph = NodeId(graph["id"].as_u64().unwrap()); + let exponent = create_text(&mut s, "x2"); + s.execute("text.setRangeStyle", &json!({"id": exponent.0, "start": 1, "end": 2, "position": "superscript"})).unwrap(); + s.execute("select.set", &json!({"ids": [exponent.0]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "Exponent", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + // Saved in pattern editing mode: Cancel puts back the definition from before the edit. + s.execute("object.pattern.edit", &json!({"name": "Exponent"})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.small_caps = Some(-5.0); + let two = &mut t.runs[1].style; + two.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX }); + two.small_caps = Some(f64::MAX); + } + if let Some(NodeKind::Text(t)) = d.node_mut(water).map(|n| &mut n.kind) { + t.runs[1].style.position = CharPosition::Subscript(ScriptMetrics { size: f64::MAX, position: -1000.0 }); + t.runs[1].style.tracking = -5000.0; + } + let mut art = d.node(id).unwrap().clone(); + art.id = NodeId(9000); + d.symbols.push(vectorcraft_doc::Symbol { name: "Formula".into(), art: Arc::new(art) }); + if let Some(g) = d.node_mut(graph).and_then(|n| n.extras_if_any()).and_then(|e| e.graph.as_deref_mut()) { + let position = CharPosition::Subscript(ScriptMetrics { size: 0.0, position: 1000.0 }); + g.label_styles.legend = Some(vectorcraft_doc::CharStyle { position, small_caps: Some(0.0), ..vectorcraft_doc::CharStyle::default() }); + } + let original = d.pattern_edit.as_mut().and_then(|pe| pe.original.as_mut()).and_then(|p| p.art.first_mut()); + if let Some(NodeKind::Text(t)) = original.map(|a| &mut Arc::make_mut(a).kind) { + t.runs[1].style.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX }); + } + let attrs = json!({"position": {"kind": "superscript", "size": 1000.0, "position": -1000.0}, "small_caps": 0.0, "tracking": -5000.0}); + d.char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() }); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d)); + s.execute("document.open", &json!({"name": "scripts.vectorcraft", "dataBase64": data})).unwrap(); + let raised = CharPosition::Superscript(ScriptMetrics { size: 1.0, position: 500.0 }); + let t = text(&s, id); + assert_eq!((t.runs[0].style.small_caps, t.runs[1].style.small_caps), (Some(1.0), Some(200.0))); + assert_eq!(t.runs[1].style.position, raised); + let t = text(&s, water); + assert_eq!(t.runs[1].style.position, CharPosition::Subscript(ScriptMetrics { size: 500.0, position: -500.0 })); + assert_eq!(t.runs[1].style.tracking, -5000.0, "other attributes open as saved"); + let doc = &s.doc().unwrap().doc; + match &doc.symbols.iter().find(|s| s.name == "Formula").unwrap().art.kind { + NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised), + _ => panic!("the symbol's art is not text"), + } + let legend = doc.node(graph).unwrap().extras().graph.as_ref().unwrap().label_styles.legend.clone().unwrap(); + assert_eq!((legend.position, legend.small_caps), (CharPosition::Subscript(ScriptMetrics { size: 1.0, position: 500.0 }), Some(1.0))); + let def = doc.char_styles.iter().find(|d| d.name == "Note").unwrap(); + assert_eq!(def.attrs["position"], json!({"kind": "superscript", "size": 500.0, "position": -500.0})); + assert_eq!((&def.attrs["small_caps"], &def.attrs["tracking"]), (&json!(1.0), &json!(-5000.0))); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + match &s.doc().unwrap().doc.pattern("Exponent").unwrap().art[0].kind { + NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised), + _ => panic!("the pattern's art is not text"), + } + // With the file's proportions kept as saved, the move failed with "result would exceed the canvas". + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); +} + #[test] fn setup_survives_save_and_load() { let mut s = session(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 02b685252..d84f2d336 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -559,6 +559,10 @@ pub fn load_file(bytes: &[u8]) -> Result { doc.limit_depth_noting(&mut depth_notes); // Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); + // Untrusted: the superscript, subscript and small caps proportions of the type, the graph + // labels and the character styles, within the ranges `document.setup` takes (after the depth + // limit: the walk recurses). + doc.keep_scripts_in_range(); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } diff --git a/docs/mcp.md b/docs/mcp.md index ba611e72b..ec3e804fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3561,6 +3561,8 @@ position}` (a range) set what the Character panel's Small Caps, Superscript and default); capitals keep their size, and with All Caps on every letter is a full-size capital. `position: "superscript"` or `"subscript"` draws the characters at Document Setup's superscript or subscript size, raised or lowered by its position (by default 58.3% of the font size, moved by 33.3% of it), and `"normal"` puts them back on the baseline. +A file whose characters, character styles or graph label styles hold a superscript or subscript size outside 1 to +500%, a position outside -500 to 500% or a small caps size outside 1 to 200% opens with the nearer end of each range. Setting superscript on subscript characters replaces the subscript, and setting subscript on superscript characters replaces the superscript. When `document.setup {superscript, subscript, smallCapsSize}` changes the proportions, the characters take the new ones. One undo step.