From 81574b1648bf835c05103b6f5d93593c5415e5ca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 02:13:58 -0700 Subject: [PATCH] Type: superscript, subscript and small caps proportions out of range in a file are read in range document.setup accepts a superscript and subscript size of 1 to 500% and a position of -500 to 500%, and a small caps size of 1 to 200%. text.setFormat copies these proportions into the characters it formats. A .vectorcraft file's characters, character styles and graph label styles opened with the proportions they held. With a superscript size of 0, the superscript characters were not drawn, and with a superscript or small caps size of 1.797e308, moving the type failed with "result would exceed the canvas". These proportions are now read as the nearer end of each range when a file opens: in the type of the layers, symbols, graph designs, patterns and the pattern definition that Cancel puts back in pattern editing mode, in graph label styles and in character styles. Proportions in range and the other character attributes open as saved. Docs: docs/mcp.md (Small Caps, Superscript and Subscript). --- crates/doc/src/placed_document.rs | 2 +- crates/doc/src/text.rs | 74 ++++++++++++++++++++++++++++ crates/engine/src/tests_docsetup.rs | 75 +++++++++++++++++++++++++++++ crates/format/src/lib.rs | 4 ++ docs/mcp.md | 2 + 5 files changed, 156 insertions(+), 1 deletion(-) 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.