Merge pull request #2757 from dotCooCoo/fix/saved-text-sizes-in-range

Type: sizes, scales, leading, indents and spacing out of range in a file are read in range
This commit is contained in:
BFlat
2026-10-18 01:51:28 +07:00
committed by GitHub
5 changed files with 225 additions and 1 deletions
+111
View File
@@ -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)]
+33
View File
@@ -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::<Vec<_>>(), [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();
+73
View File
@@ -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::<Vec<_>>()
};
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#"<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="3000">
<text x="10" y="2000" font-size="2000">Big</text>
<text x="10" y="2400" font-size="0.05">tiny</text>
<text x="200" y="2600" font-size="12" text-anchor="middle"><tspan x="200" y="2600">One</tspan><tspan x="2900" y="2614.4" text-anchor="start">Two</tspan></text>
</svg>"#;
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<f64> = 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);
}
+3
View File
@@ -557,6 +557,9 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
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
+5 -1
View File
@@ -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 (`<tspan text-anchor>`), 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}}}