Scaling a text frame scales its text, as InDesign's When Scaling: Apply to Content

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-15 10:15:49 +02:00
co-authored by Claude Opus 5.5
parent a930276fd7
commit 2f05a52ff0
+168 -5
View File
@@ -69,9 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
let c = union_bounds(d, &ids).center();
let m = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-c.to_vec2());
for id in &ids {
if let Some(it) = d.item_mut(*id) {
scale_item(it, m, (sx * sy).abs().sqrt(), strokes);
}
scale_object(d, *id, m, (sx * sy).abs().sqrt(), strokes);
}
ok()
})
@@ -1122,7 +1120,7 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
let c = it.bounds().center();
it.xf = Affine::translate(m * c - c) * it.xf;
} else if scale_content {
scale_item(it, m, (sx * sy).abs().sqrt(), strokes);
scale_object(d, *id, m, (sx * sy).abs().sqrt(), strokes);
} else if it.flex.is_some() {
resize_container(it, m);
} else if matches!(it.content, Content::Group { .. }) {
@@ -1217,6 +1215,108 @@ pub(crate) fn scale_item(it: &mut Item, m: Affine, k: f64, strokes: bool) {
}
}
/// Scale the object `id` and its content by `m` (see [`scale_item`]); a text frame's text takes the
/// scale as [`scale_text_frame`] sets it.
fn scale_object(d: &mut Document, id: ItemId, m: Affine, k: f64, strokes: bool) {
if scale_text_frame(d, id, m, k, strokes) {
return;
}
if let Some(it) = d.item_mut(id) {
scale_item(it, m, k, strokes);
}
}
/// Scale a text frame as InDesign's default "When Scaling: Apply to Content" does: the frame's
/// path takes the scale, so it shows 100%, and the text is set larger or smaller. The point size,
/// leading, baseline shift, text stroke and underline weights follow the vertical scale, the
/// horizontal scale takes the difference between the two axes, and the indents and paragraph
/// spacing follow their own axes. False (the frame is then transformed as a whole) for a story that
/// runs through other frames, vertical text, and a scale that flips or isn't along the frame's own
/// axes.
fn scale_text_frame(d: &mut Document, id: ItemId, m: Affine, k: f64, strokes: bool) -> bool {
let Some(it) = d.item(id) else { return false };
let Content::Text(tf) = &it.content else { return false };
let story = tf.story;
let inner = it.xf.inverse() * m * it.xf;
let [sx, b, c, sy, _, _] = inner.as_coeffs();
if b.abs() > 1e-9 || c.abs() > 1e-9 || !(sx > 1e-6 && sy > 1e-6) || !sx.is_finite() || !sy.is_finite() {
return false;
}
let Some(st) = d.story(story) else { return false };
if st.frames.len() != 1 || st.vertical {
return false;
}
// The resolved attributes of every run, split at paragraph ends (a run's size can come from its
// paragraph's style). Each paragraph keeps its mark.
let mut runs = Vec::new();
let mut paras = Vec::new();
for (i, pr) in st.para_ranges().into_iter().enumerate() {
let Some(pf) = st.paras.get(i) else { break };
let (pp, base) = d.styles.resolve_para(pf);
let end = pr.end.saturating_add(1).min(st.text.len());
for (r, f) in st.runs() {
let (a, z) = (r.start.max(pr.start), r.end.min(end));
if a < z {
runs.push((a..z, d.styles.resolve_char(&base, f)));
}
}
paras.push((pr.start, pp));
}
let h = sx / sy;
let Some(st) = d.story_mut(story) else { return false };
for (r, cp) in runs {
st.format_chars(r, |f| {
let o = &mut f.over;
o.size = Some(cp.size * sy);
if let designcraft_doc::Leading::Points(v) = cp.leading {
o.leading = Some(designcraft_doc::Leading::Points(v * sy));
}
if cp.baseline_shift != 0.0 {
o.baseline_shift = Some(cp.baseline_shift * sy);
}
if (h - 1.0).abs() > 1e-9 {
o.h_scale = Some(cp.h_scale * h);
}
if cp.stroke != "[None]" {
o.stroke_weight = Some(cp.stroke_weight * sy);
}
for (v, w) in [
(&mut o.underline_weight, cp.underline_weight),
(&mut o.underline_offset, cp.underline_offset),
(&mut o.strikethrough_weight, cp.strikethrough_weight),
(&mut o.strikethrough_offset, cp.strikethrough_offset),
] {
if let Some(w) = w {
*v = Some(Some(w * sy));
}
}
});
}
for (at, pp) in paras {
st.format_paras(at..at, |p| {
let a = &mut p.para;
for (v, w, s) in [
(&mut a.left_indent, pp.left_indent, sx),
(&mut a.right_indent, pp.right_indent, sx),
(&mut a.first_line_indent, pp.first_line_indent, sx),
(&mut a.last_line_indent, pp.last_line_indent, sx),
(&mut a.space_before, pp.space_before, sy),
(&mut a.space_after, pp.space_after, sy),
] {
if w != 0.0 {
*v = Some(w * s);
}
}
});
}
let Some(it) = d.item_mut(id) else { return false };
bake(it, inner);
if strokes {
it.stroke.weight *= k;
}
true
}
fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let (list, _, _) = &s.transforms;
@@ -1350,7 +1450,7 @@ fn transform_values(s: &mut Session, p: &Value, ids: &[ItemId], rf: usize) -> Re
let (fx, fy) = (sx.map_or(1.0, |x| x / 100.0 / v.scale_x), sy.map_or(1.0, |y| y / 100.0 / v.scale_y));
let m = Affine::translate(a.to_vec2()) * Affine::scale_non_uniform(fx, fy) * Affine::translate(-a.to_vec2());
let m = parent.inverse() * m * parent;
scale_item(it, m, (fx * fy).abs().sqrt(), strokes);
scale_object(d, *id, m, (fx * fy).abs().sqrt(), strokes);
}
}
ok()
@@ -2713,6 +2813,69 @@ mod scale_tests {
s.execute("transform.set", &json!({"width": 120})).unwrap();
assert!((bounds(&s).width() - 120.0).abs() < 1e-9);
}
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (ItemId, StoryId) {
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap();
(ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap()))
}
fn props_at(s: &Session, story: StoryId, pos: usize) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) {
let d = &s.doc().unwrap().doc;
let st = d.story(story).unwrap();
let (pp, base) = d.styles.resolve_para(&st.paras[st.para_at(pos)]);
(pp, d.styles.resolve_char(&base, st.char_format_at(pos)))
}
/// InDesign's default When Scaling: Apply to Content: Cmd+Shift-dragging a text frame's handle
/// sets the text larger and leaves the frame at 100% (Dansky's Exclude Overlap tutorial, 2:16).
#[test]
fn scaling_a_text_frame_scales_its_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let (f, story) = text_frame(&mut s, [100.0, 100.0, 300.0, 200.0], "Learn\nmore");
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 5})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 20, "leading": {"kind": "points", "value": 24}, "baselineShift": 2, "tracking": -50}})).unwrap();
s.execute("type.para", &json!({"attrs": {"leftIndent": 4, "spaceAfter": 6}})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
// The handle drag: from the top-left, twice as wide and high.
s.execute("transform.resize", &json!({"ids": [f.0], "from": [100, 100, 300, 200], "to": [100, 100, 500, 300], "content": true})).unwrap();
let (pp, cp) = props_at(&s, story, 0);
assert_eq!((cp.size, cp.leading, cp.baseline_shift, cp.tracking, cp.h_scale), (40.0, designcraft_doc::Leading::Points(48.0), 4.0, -50.0, 1.0));
assert_eq!((pp.left_indent, pp.space_after), (8.0, 12.0));
// The second paragraph's size came from the paragraph style: it is set too.
assert_eq!(props_at(&s, story, 7).1.size, 24.0);
let it = s.doc().unwrap().doc.item(f).unwrap().clone();
assert_eq!(it.bounds(), Rect::new(100.0, 100.0, 500.0, 300.0));
let scale = s.execute("transform.info", &json!({"ids": [f.0]})).unwrap();
assert_eq!((scale["scaleX"].as_f64(), scale["scaleY"].as_f64()), (Some(100.0), Some(100.0)), "{scale}");
// Wider than high: the horizontal scale takes the difference.
s.execute("transform.scale", &json!({"ids": [f.0], "sx": 1.0, "sy": 0.5})).unwrap();
let (_, cp) = props_at(&s, story, 0);
assert_eq!((cp.size, cp.h_scale), (20.0, 2.0));
// The Transform panel's scale fields do the same, and show 100% afterwards.
s.execute("transform.set", &json!({"ids": [f.0], "scaleX": 50, "scaleY": 50})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 10.0);
// One undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 20.0);
}
/// A threaded story, a rotated scale and a flip keep the old behaviour: the frame is
/// transformed as a whole and the text keeps its size.
#[test]
fn threaded_and_flipped_text_frames_scale_as_a_whole() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let (a, story) = text_frame(&mut s, [100.0, 100.0, 200.0, 140.0], "Some threaded text");
let (b, _) = text_frame(&mut s, [100.0, 200.0, 200.0, 240.0], "");
s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap();
s.execute("transform.scale", &json!({"ids": [a.0], "sx": 2.0})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 12.0);
let (c, story) = text_frame(&mut s, [300.0, 100.0, 400.0, 140.0], "Flip");
s.execute("transform.scale", &json!({"ids": [c.0], "sx": -1.0, "sy": 1.0})).unwrap();
assert_eq!(props_at(&s, story, 0).1.size, 12.0);
assert!(s.doc().unwrap().doc.item(c).unwrap().xf.as_coeffs()[0] < 0.0);
}
}
#[cfg(test)]