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}}}