diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8c1b9bfd0..a3aad9243 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1402,6 +1402,36 @@ impl TextObject { Some(style) } + /// Bakes the object's scale ([`style_scale`]) into character attributes and removes that + /// scale from `xf`, so panels show document-point sizes without a separate transform factor. + /// Returns `false` when there is nothing to bake (identity scale or an unrepresentable + /// transform). + pub fn scale_text_attributes(&mut self) -> bool { + let Some(scale) = self.style_scale() else { return false }; + if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 { + return false; + } + let [a, b, c, d, _, _] = self.xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + if sx <= 1e-12 || sy <= 1e-12 { + return false; + } + let mut baked = Vec::with_capacity(self.runs.len()); + for run in &self.runs { + let Some(eff) = self.effective_char_style(&run.style) else { return false }; + baked.push(eff); + } + for (run, style) in self.runs.iter_mut().zip(baked) { + run.style = style; + } + // Character strokes are drawn in text space: widen them by the vertical scale we remove. + self.scale_char_strokes(1.0 / sy); + self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); + self.cached_bounds = None; + self.cached_baselines.clear(); + true + } + pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { vertical: false, diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index fe7b24205..a241bff3c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -34,6 +34,15 @@ pub fn specs() -> Vec { has_selection, create_outlines ), + cmd!( + "type.scaleTextAttributes", + "Scale Text Attributes", + ["Type"], + None, + "{ids?|id?} bake each selected type object's transform scale into its character sizes (leading, baseline shift and horizontal scale included), leaving rotation and position in the affine → {ids, changed}", + scale_text_attributes_enabled, + scale_text_attributes + ), cmd!( "type.opticalMarginAlignment", "Optical Margin Alignment", @@ -386,6 +395,42 @@ fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { out } +fn scale_text_attributes_enabled(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let ids = text_ids(&st.doc, &st.selection.objects); + if ids.iter().any(|id| { + st.doc.node(*id).is_some_and(|n| { + let NodeKind::Text(t) = &n.kind else { return false }; + t.style_scale().is_some_and(|sc| (sc.points - 1.0).abs() > 1e-12 || (sc.horizontal - 1.0).abs() > 1e-12) + }) + }) { + Ok(()) + } else { + Err("Scale Text Attributes: selected type has no scale to bake".into()) + } +} + +fn scale_text_attributes(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.scaleTextAttributes"; + let ids = text_targets(s, p, C)?; + let changed = s.edit("Scale Text Attributes", |d, _| { + let mut n = 0usize; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|node| &mut node.kind) else { continue }; + if t.scale_text_attributes() { + refresh_bounds(t); + n += 1; + } + } + if n == 0 { + return Err(EngineError::Other("Scale Text Attributes: selected type has no scale to bake".into())); + } + Ok(n) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "changed": changed })) +} + fn create_outlines(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Create Outlines", |d, sel| { diff --git a/crates/engine/src/tests_typescale.rs b/crates/engine/src/tests_typescale.rs index 06b613271..c05326861 100644 --- a/crates/engine/src/tests_typescale.rs +++ b/crates/engine/src/tests_typescale.rs @@ -300,3 +300,24 @@ fn imported_type_with_sizes_outside_the_panels_range_opens_unchanged() { s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": saved})).unwrap(); assert_eq!(texts(&s), imported); } + +#[test] +fn scale_text_attributes_bakes_group_scale_and_undoes() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + assert_eq!(text(&s, id).runs[0].style.size, 12.0); + s.execute("type.scaleTextAttributes", &json!({})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + near(text(&s, id).runs[0].style.size, 24.0); + assert!(text(&s, id).style_scale().is_none_or(|sc| (sc.points - 1.0).abs() <= 1e-9 && (sc.horizontal - 1.0).abs() <= 1e-9)); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + near(em(&text(&s, id), 0).1, 10.0); + s.execute("edit.undo", &json!({})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + near(text(&s, id).runs[0].style.size, 24.0); + s.execute("edit.undo", &json!({})).unwrap(); + near(text(&s, id).runs[0].style.size, 12.0); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0c59b761f..9b2586a9f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2887,6 +2887,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), Sep, c("Create Outlines", "type.createOutlines"), + c("Scale Text Attributes", "type.scaleTextAttributes"), c("Optical Margin Alignment", "type.opticalMarginAlignment"), Sep, c("Fill with Placeholder Text", "type.fillPlaceholder"),