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).
This commit is contained in:
dotCooCoo
2026-10-18 01:21:24 +07:00
committed by bflatastic
parent f7382b2844
commit 81574b1648
5 changed files with 156 additions and 1 deletions
+1 -1
View File
@@ -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);
+74
View File
@@ -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::<CharPosition>(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).
+75
View File
@@ -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();
+4
View File
@@ -559,6 +559,10 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
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 })
}
+2
View File
@@ -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.