G4: Scale Text Attributes bakes transform scale into type sizes

After scaling groups, type.scaleTextAttributes matches panel sizes to what draws on canvas without leaving scale in the affine.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-21 23:38:03 +07:00
committed by bflatastic
co-authored by Cursor
parent 240d8d4891
commit 07a49d447d
4 changed files with 97 additions and 0 deletions
+30
View File
@@ -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,
+45
View File
@@ -34,6 +34,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<NodeId> {
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<Value> {
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::<Vec<_>>(), "changed": changed }))
}
fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let ids = s.edit("Create Outlines", |d, sel| {
+21
View File
@@ -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);
}
+1
View File
@@ -2887,6 +2887,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
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"),