mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-10 10:30:20 -06:00
Merge PR #1007: Scaling a text frame scales its text, as InDesign's When Scaling: Apply to Content
This commit is contained in:
@@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-15** · Scaling a text frame (Cmd+Shift-dragging a handle, the Scale tool, Transform panel scale fields) sets its text larger or smaller and leaves the frame at 100%, as InDesign's default When Scaling: Apply to Content does; it used to keep the text's size with the scale on the frame. No readiness percentage change.
|
||||
|
||||
- **2026-10-15** · With nothing selected, the Tools panel's fill and stroke (the Color Picker, swatches, Apply None, Swap, Default) set the colours new shapes are drawn with, as InDesign's document defaults; the proxy shows them. They were refused ("nothing selected").
|
||||
|
||||
- **2026-10-15** · The Eyedropper with text selected picks up the formatting of the text it clicks (character style and overrides, paragraph style and overrides) and applies it to the selection, as InDesign's does; it copied the clicked frame's fill and stroke instead. No readiness percentage change.
|
||||
|
||||
@@ -77,9 +77,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()
|
||||
})
|
||||
@@ -1222,7 +1220,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 { .. }) {
|
||||
@@ -1320,6 +1318,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;
|
||||
@@ -1466,7 +1566,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()
|
||||
@@ -3693,6 +3793,73 @@ 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)]
|
||||
|
||||
Reference in New Issue
Block a user