From e5945c67958694b3a4786bff718b43e51a688baf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 07:45:12 -0700 Subject: [PATCH] Type: sizes, scales, leading, indents and spacing out of range in a file are read in range A .vectorcraft file's type opened with the character and paragraph values it held. With a size of 0, the characters were not drawn. With a size, leading, horizontal scale, left indent or space before of 1.797e308, point type's bounds were infinite or over 1e308 wide or high, and moving or scaling it failed with "result would exceed the canvas". These values are now read into 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, and in character and paragraph styles. A size, horizontal scale or vertical scale of 0 or less becomes 12 pt or 100%, and a negative leading becomes Auto. A size, leading or scale past 4,000,000 (MAX_COORD) becomes 4,000,000, and a baseline shift, indent or space before or after past 4,000,000 either way becomes 4,000,000 or -4,000,000. A tab stop is kept to the 0 to 100,000 pt that text.tabs.set takes. A leading of 0, which legacy .ai import stores, and the sizes, scales and indents within these limits that SVG, PDF and .ai import keep outside the panels' ranges open as saved. Docs: docs/mcp.md (Paragraph attributes per paragraph). --- crates/doc/src/text.rs | 111 ++++++++++++++++++++++++++ crates/engine/src/tests_paragraphs.rs | 33 ++++++++ crates/engine/src/tests_typescale.rs | 73 +++++++++++++++++ crates/format/src/lib.rs | 3 + docs/mcp.md | 6 +- 5 files changed, 225 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b13f372dd..611edc1c8 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -293,6 +293,20 @@ impl CharStyle { pub fn effective_leading(&self) -> f64 { self.leading.unwrap_or(self.size * 1.2) } + /// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size + /// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading + /// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way) + /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a + /// baseline shift that is not a number becomes 0. + pub fn keep_dimensions_in_range(&mut self) { + let default = Self::default(); + let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; + self.size = within(self.size, default.size); + self.h_scale = within(self.h_scale, default.h_scale); + self.v_scale = within(self.v_scale, default.v_scale); + self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD)); + self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) }; + } /// Do the characters draw a stroke (a paint and a positive weight)? pub fn has_stroke(&self) -> bool { !self.stroke.is_none() && self.stroke_width > 0.0 @@ -538,6 +552,22 @@ pub struct ParaStyle { pub roman_hanging: bool, } +impl ParaStyle { + /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to + /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range + /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is + /// not a number becomes 0. + pub fn keep_spacing_in_range(&mut self) { + let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) }; + for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] { + *v = within(*v, -crate::MAX_COORD, crate::MAX_COORD); + } + for t in &mut self.tabs { + t.position = within(t.position, 0.0, 100_000.0); + } + } +} + /// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph /// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto /// read the alignment and ignore the flag, and builds with it read Auto back. @@ -1445,6 +1475,87 @@ impl TextObject { }); self.cached_baselines.iter().copied().chain(first) } + /// Keeps every run's size, leading, baseline shift and scales in range + /// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab + /// stops ([`ParaStyle::keep_spacing_in_range`]). + pub fn keep_type_values_in_range(&mut self) { + for r in &mut self.runs { + r.style.keep_dimensions_in_range(); + } + for p in self.para_styles_mut() { + p.keep_spacing_in_range(); + } + } +} + +impl crate::Document { + /// Keeps the character dimensions, indents, paragraph spacing and tab stops in range + /// ([`TextObject::keep_type_values_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), and the same attributes of the character and paragraph + /// styles. It recurses through the art: call it on a document no deeper than + /// [`crate::depth::MAX_DEPTH`]. + pub fn keep_type_values_in_range(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + t.keep_type_values_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); + } + // Styles store the attributes they set by their serialized names (a number, or null for + // Auto leading); a number is rewritten only when it changes. + for def in self.char_styles.iter_mut() { + let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64); + let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() }; + st.size = num("size").unwrap_or(st.size); + st.h_scale = num("h_scale").unwrap_or(st.h_scale); + st.v_scale = num("v_scale").unwrap_or(st.v_scale); + st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift); + st.keep_dimensions_in_range(); + let kept = [ + ("size", Some(st.size)), + ("h_scale", Some(st.h_scale)), + ("v_scale", Some(st.v_scale)), + ("baseline_shift", Some(st.baseline_shift)), + ("leading", st.leading), + ]; + for (k, v) in kept { + if let Some(old) = def.attrs.get_mut(k) + && old.is_number() + && old.as_f64() != v + { + *old = v.into(); + } + } + } + for def in self.para_styles.iter_mut() { + for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] { + if let Some(v) = def.attrs.get_mut(k) + && let Some(x) = v.as_f64() + && x.abs() > crate::MAX_COORD + { + *v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into(); + } + } + for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() { + if let Some(v) = t.get_mut("position") + && let Some(x) = v.as_f64() + && !(0.0..=100_000.0).contains(&x) + { + *v = x.clamp(0.0, 100_000.0).into(); + } + } + } + } } #[cfg(test)] diff --git a/crates/engine/src/tests_paragraphs.rs b/crates/engine/src/tests_paragraphs.rs index 114c39461..3e4f24907 100644 --- a/crates/engine/src/tests_paragraphs.rs +++ b/crates/engine/src/tests_paragraphs.rs @@ -66,6 +66,39 @@ fn ranged_paragraph_commands_touch_only_their_paragraphs() { assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph"); } +/// A file whose paragraphs hold an indent or a space before or after past `MAX_COORD`, or a tab +/// stop outside 0 to 100,000 pt, opens with the nearer end of each range, in the type and in +/// paragraph styles. Values past the Paragraph panel's 1296 pt and within those ranges stay. +#[test] +fn a_file_with_paragraph_spacing_out_of_range_opens_with_it_in_range() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 40, "y": 60, "size": 20, "text": "Line\tone\nLine two"})).unwrap()["id"].as_u64().unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + let tab = |position| vectorcraft_doc::TabStop { position, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' }; + let mut first = t.para.clone(); + (first.left_indent, first.first_line_indent, first.space_after) = (f64::MAX, -1e7, 5000.0); + first.tabs = vec![tab(-5.0), tab(f64::MAX)]; + let mut second = t.para.clone(); + (second.right_indent, second.space_before) = (-f64::MAX, 1e7); + t.set_paragraph_styles(vec![first, second]); + let attrs = json!({"left_indent": 1e9, "space_after": 2000, "tabs": [serde_json::to_value(tab(-5.0)).unwrap()]}); + d.para_styles.push(vectorcraft_doc::TextStyleDef { name: "Wide".into(), attrs: attrs.as_object().unwrap().clone() }); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d)); + s.execute("document.open", &json!({"name": "spacing.vectorcraft", "dataBase64": data})).unwrap(); + let max = vectorcraft_doc::MAX_COORD; + let t = text(&s, id); + let (p0, p1) = (t.para_at(0), t.para_at(1)); + assert_eq!((p0.left_indent, p0.first_line_indent, p0.space_after), (max, -max, 5000.0)); + assert_eq!(p0.tabs.iter().map(|t| t.position).collect::>(), [0.0, 100_000.0]); + assert_eq!((p1.right_indent, p1.space_before), (-max, max)); + let def = &s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Wide").unwrap().attrs; + assert_eq!((&def["left_indent"], &def["space_after"], &def["tabs"][0]["position"]), (&json!(max), &json!(2000), &json!(0.0))); + // With the spacing kept as saved, the type's bounds were infinite. + let b = s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap(); + assert!(b.width().is_finite() && b.height().is_finite(), "{b:?}"); +} + #[test] fn editing_splits_and_merges_paragraph_styles() { let mut s = session(); diff --git a/crates/engine/src/tests_typescale.rs b/crates/engine/src/tests_typescale.rs index cfb4e1517..06b613271 100644 --- a/crates/engine/src/tests_typescale.rs +++ b/crates/engine/src/tests_typescale.rs @@ -227,3 +227,76 @@ fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() { assert_eq!(text(&s, id), before); assert_eq!(s.doc().unwrap().history.undo.len(), history); } + +/// A file whose type holds a size or scale of 0 or less or a negative leading opens with the +/// default size and scale and Auto leading, and a size, leading, baseline shift or scale past +/// `MAX_COORD` opens with `MAX_COORD`, in the layers, in symbols and in character styles. A +/// leading of 0, a size below 0.1 pt and scales below 1% stay as saved. +#[test] +fn a_file_with_type_sizes_out_of_range_opens_with_them_in_range() { + let mut s = session(); + let p = json!({"x": 40, "y": 60, "size": 20, "text": "Line one\nLine two\nLine three"}); + let id = NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 9, "end": 18, "size": 30})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 18, "end": 28, "size": 40})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") }; + let [zero, huge, kept] = t.runs.as_mut_slice() else { panic!("not three runs") }; + let (z, h, k) = (&mut zero.style, &mut huge.style, &mut kept.style); + (z.size, z.h_scale, z.v_scale, z.baseline_shift, z.leading) = (0.0, -100.0, 0.0, -f64::MAX, Some(-50.0)); + (h.size, h.h_scale, h.v_scale, h.baseline_shift, h.leading) = (f64::MAX, f64::MAX, f64::MAX, f64::MAX, Some(f64::MAX)); + (k.size, k.h_scale, k.v_scale, k.baseline_shift, k.leading) = (0.001, 0.5, 0.5, 5000.0, Some(0.0)); + let art = d.node(id).unwrap().clone(); + d.symbols.push(vectorcraft_doc::Symbol { name: "Lines".into(), art: Arc::new(art) }); + let attrs = json!({"size": 0, "h_scale": 1e9, "v_scale": -1, "baseline_shift": -1e9, "leading": -1}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Broken".into(), attrs: attrs.as_object().unwrap().clone() }); + let attrs = json!({"size": 40, "leading": null}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Auto".into(), attrs: attrs.as_object().unwrap().clone() }); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d)); + s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": data})).unwrap(); + let dims = |t: &TextObject| { + t.runs.iter().map(|r| (r.style.size, r.style.h_scale, r.style.v_scale, r.style.baseline_shift, r.style.leading)).collect::>() + }; + let max = vectorcraft_doc::MAX_COORD; + let want = vec![(12.0, 100.0, 100.0, -max, None), (max, max, max, max, Some(max)), (0.001, 0.5, 0.5, 5000.0, Some(0.0))]; + assert_eq!(dims(&text(&s, id)), want); + let doc = &s.doc().unwrap().doc; + let NodeKind::Text(art) = &doc.symbols[0].art.kind else { panic!("not text") }; + assert_eq!(dims(art), want); + let fixed = json!({"size": 12.0, "h_scale": max, "v_scale": 100.0, "baseline_shift": -max, "leading": null}); + assert_eq!(doc.char_styles[0].attrs, *fixed.as_object().unwrap()); + assert_eq!(doc.char_styles[1].attrs, *json!({"size": 40, "leading": null}).as_object().unwrap()); + // With the sizes kept as saved, the type's bounds were infinite. + let b = doc.bounds_of(&[id], false).unwrap(); + assert!(b.width().is_finite() && b.height().is_finite(), "{b:?}"); +} + +/// SVG import keeps type sizes outside the Character panel's 0.1 to 1296 pt, and it gives a line +/// aligned differently from the one before it an indent, here past the Paragraph panel's 1296 pt. +/// A native file of the result opens with the same type. +#[test] +fn imported_type_with_sizes_outside_the_panels_range_opens_unchanged() { + const SVG: &str = r#" + Big + tiny + OneTwo + "#; + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "sizes.svg", "dataBase64": vectorcraft_format::base64_encode(SVG.as_bytes())})).unwrap(); + let texts = |s: &Session| { + let mut out = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + out.push((**t).clone()); + } + }); + out + }; + let imported = texts(&s); + let sizes: Vec = imported.iter().flat_map(|t| t.runs.iter().map(|r| r.style.size)).collect(); + assert!(sizes.iter().any(|v| *v > 1296.0) && sizes.iter().any(|v| *v < 0.1), "{sizes:?}"); + assert!(imported.iter().flat_map(|t| t.para_styles()).any(|p| p.left_indent > 1296.0), "an indent past the panel's"); + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": saved})).unwrap(); + assert_eq!(texts(&s), imported); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d84f2d336..751d3487a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -557,6 +557,9 @@ pub fn load_file(bytes: &[u8]) -> Result { doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); + // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph + // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). + doc.keep_type_values_in_range(); // 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 diff --git a/docs/mcp.md b/docs/mcp.md index ec3e804fd..2ff3d99c6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3707,7 +3707,11 @@ the story re-flows (a paragraph split between frames has them in both). In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every paragraph is alike) one entry per paragraph, so older readers keep the first paragraph's. SVG export anchors each centred or right-aligned line on its own (``), and SVG import gives each line's paragraph its -alignment back, with indents that keep it in place. +alignment back, with indents that keep it in place. When a native file's type or character styles hold a size, a +horizontal scale or a vertical scale of 0 or less, the value opens as 12 pt or 100%. A negative leading opens as Auto, +and a size, leading or scale past 4,000,000 opens as 4,000,000. A baseline shift, an indent or a space before or after +past 4,000,000 pt either way, in the type or in character or paragraph styles, opens at that limit, and a tab stop +outside 0 to 100,000 pt opens at the nearer end. ```json {"name":"run_command","arguments":{"command":"text.setStyle","params":{"id":42,"justify":"center","start":0,"end":0}}}