Merge remote-tracking branch 'origin/main' into fix/ruler-double-click-guide

This commit is contained in:
Taha-Mahmoodi
2026-11-11 13:36:04 +04:30
20 changed files with 255 additions and 30 deletions
+5 -1
View File
@@ -544,7 +544,11 @@ fn merge(chain: &[&Piece]) -> std::result::Result<TextObject, usize> {
for (k, w) in off.windows(2).enumerate() {
let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue };
if delta.abs() > 1e-9 {
edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0);
// Thousandths of the em as the Horizontal Scale draws it (`spacing_scale`).
let vertical = m.vertical;
edit::style_range(&mut m.runs, s, e, |st| {
st.tracking += delta / (st.size * vectorcraft_text::spacing_scale(st, vertical)).max(1e-6) * 1000.0;
});
if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) {
return Err(k + 1);
}
@@ -176,6 +176,24 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() {
assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine"));
}
/// Pieces in horizontally scaled type: the tracking that puts each back where it was is measured in
/// the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), as layout spaces it.
#[test]
fn combine_puts_horizontally_scaled_pieces_back_where_they_were() {
let mut s = session();
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
let b = text(&mut s, json!({"x": 160, "y": 100, "text": "Right", "size": 20}));
for id in [a, b] {
s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 200})).unwrap();
}
let was = glyphs(&s, &[a, b]);
select(&mut s, &[a, b]);
let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap();
assert_eq!(ids(&r), [a]);
assert_eq!(obj(&s, a).plain_text(), "Left Right");
assert_same_places(&was, &glyphs(&s, &[a]));
}
/// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs,
/// pieces on a line left to right, each line's baseline where it was (its leading is the distance
/// from the line above), in one undo step. A piece turned unlike the top-left one stays.
+2 -1
View File
@@ -1578,7 +1578,8 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) if v.is_array() => super::select::checked_refs(Some(v), "path.reshape")?.into_iter().collect(),
Some(_) => return Err(bad("path.reshape", "`focal` must be an array of [subpath, anchor] pairs")),
};
if radius.is_some() && !focal.is_empty() {
// An empty `focal` chooses the focal-point rule too.
if radius.is_some() && !matches!(p.get("focal"), None | Some(Value::Null)) {
return Err(bad("path.reshape", "`focal` and `radius` cannot be used together"));
}
// A step that names neither `focal` nor `radius` (as the Reshape tool recorded one before it
+7 -3
View File
@@ -1087,12 +1087,16 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
/// was shaped at (superscript, small caps and Shrink Text to Fit included).
/// was shaped at (superscript, small caps and Shrink Text to Fit included), in horizontal type
/// as its Horizontal Scale draws it ([`vectorcraft_text::spacing_scale`]).
fn glyph_em(db: &vectorcraft_text::FontDb, t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64> {
let face = db.face_by_id(g.font_id)?;
let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0;
let runs = edit::slice_runs(&t.runs, g.byte, g.byte + g.len);
let style = &runs.first()?.style;
// The glyph's own scale takes in the Horizontal Scale.
let h_scale = style.h_scale / 100.0;
let [a, b, ..] = g.xf.as_coeffs();
Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0)
Some(a.hypot(b) * face.units_per_em() / h_scale * vectorcraft_text::spacing_scale(style, t.vertical)).filter(|em| em.is_finite() && *em > 0.0)
}
/// The current pair correction in thousandths of an em, including an automatic correction.
+6 -3
View File
@@ -3622,9 +3622,12 @@ fn reshape_refuses_a_focal_point_that_is_not_an_anchor() {
}
assert_eq!(reshape_anchors(&s, id), before);
let steps = s.doc().unwrap().history.undo.len();
let e =
s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": [[0, 2]], "radius": 50.0})).unwrap_err();
assert!(e.to_string().contains("`focal` and `radius` cannot be used together"), "{e}");
// An empty `focal` chooses the focal-point rule too, so it cannot go with `radius` either.
for focal in [json!([[0, 2]]), json!([])] {
let e =
s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": focal, "radius": 50.0})).unwrap_err();
assert!(e.to_string().contains("`focal` and `radius` cannot be used together"), "{focal}: {e}");
}
assert_eq!(reshape_anchors(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
+31
View File
@@ -280,6 +280,37 @@ fn metrics_pair_step_preserves_the_drawn_auto_position() {
assert!((moved - s.prefs.tracking_increment * 0.1).abs() < 0.01, "V moves by one kerning increment: {moved}");
}
/// At 50% Horizontal Scale the font's pair reads as the same thousandths of an em as at 100%:
/// kerning is measured in the em as the scale draws it (`vectorcraft_text::spacing_scale`), so the
/// value read keeps V where the font's kerning draws it, and a step moves V half as far as at 100%.
#[test]
fn horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it() {
use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(pair_kerning_font().unwrap());
let mut s = session();
let id = NodeId(
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "AV", "size": 100, "font": PAIR_KERNING_FAMILY})).unwrap()["id"].as_u64().unwrap(),
);
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 2, "hScale": 50})).unwrap();
let before = obj(&s, id);
assert_eq!(before.runs[0].style.h_scale, 50.0);
let auto = vectorcraft_text::layout(db, &before);
let drawn_v = |layout: &vectorcraft_text::TextLayout| layout.glyphs.iter().find(|g| g.byte == 1).unwrap().xf.as_coeffs()[4];
let read = crate::cmd::effective_kerning(db, &before, &auto, 0, 1);
assert!((read + 160.0).abs() < 0.01, "the font's -160/1000 em: {read}");
let mut same = before.clone();
vectorcraft_text::edit::style_range(&mut same.runs, 0, 1, |st| st.kerning = Some(read));
assert!((drawn_v(&vectorcraft_text::layout(db, &same)) - drawn_v(&auto)).abs() < 0.01);
s.execute("type.step", &json!({"id": id.0, "start": 1, "end": 1, "attribute": "kerning", "by": 1})).unwrap();
let after = obj(&s, id);
assert!((after.runs[0].style.kerning.unwrap() + 160.0 - s.prefs.tracking_increment).abs() < 0.01);
let moved = drawn_v(&vectorcraft_text::layout(db, &after)) - drawn_v(&auto);
assert!((moved - s.prefs.tracking_increment * 0.05).abs() < 0.01, "V moves by half a kerning increment at 100%: {moved}");
}
#[test]
fn effective_auto_kerning_at_area_line_end_survives_unkerned_wrap() {
use vectorcraft_text::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
+24
View File
@@ -345,3 +345,27 @@ fn scale_text_attributes_bakes_group_scale_and_undoes() {
s.execute("edit.undo", &json!({})).unwrap();
near(text(&s, id).runs[0].style.size, 12.0);
}
/// Scale Text Attributes bakes a horizontal stretch into the Horizontal Scale: tracked type stays
/// where the stretch drew it, as tracking is measured in the em the Horizontal Scale draws
/// (`vectorcraft_text::spacing_scale`). It used to lose the stretch's share of the tracking.
#[test]
fn scale_text_attributes_keeps_tracked_type_where_it_was() {
let mut s = session();
let id = create(&mut s, 20.0);
s.execute("text.setRangeStyle", &json!({"id": id.0, "tracking": 300})).unwrap();
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let pens = |s: &Session| {
let t = text(s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
lay.glyphs.iter().map(|g| t.xf * (g.xf * Point::ZERO)).collect::<Vec<_>>()
};
let before = pens(&s);
s.execute("type.scaleTextAttributes", &json!({})).unwrap();
near(text(&s, id).runs[0].style.h_scale, 200.0);
let after = pens(&s);
assert_eq!(before.len(), after.len());
for (b, a) in before.iter().zip(&after) {
assert!((*a - *b).hypot() < 1e-6, "{before:?} {after:?}");
}
}