feat(guides): New Guide Layout takes lengths with units (#8719)

* feat(guides): New Guide Layout takes lengths with units

view.newGuideLayout took pixels only. Its widths, gutters and margins now
also take text with a unit as typed in Photoshop's dialog ("1 cm",
"0.25in", "12 pt", "3pc", "10%"), at the document's resolution; percent
is of the target's width for columns, gutters and left/right margins and
of its height for rows and top/bottom margins, per artboard. The dialog's
Width and Height fields already pass such text through, so they accept
units now too.

Part of #4984

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guides): New Guide Layout percent resolves per selected artboard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guides): assert the full guide lists after a selected-artboard layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FinnNk
2026-10-23 17:40:06 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 44db720b22
commit 159fe710a2
3 changed files with 100 additions and 11 deletions
+1 -1
View File
@@ -2061,7 +2061,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Guide Layout…",
&["View"],
None,
r##"{"columns":n=0,"width":px?,"gutter":px=0,"rows":n=0,"height":px?,"rowGutter":px=gutter,"margin":px|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"}"##,
r##"{"columns":n=0,"width":len?,"gutter":len=0,"rows":n=0,"height":len?,"rowGutter":len=gutter,"margin":len|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"} (len: pixels, or text with px, in, cm, mm, pt, pc or % as typed in Photoshop, e.g. "1 cm"; % is of the target's width for columns, gutters and left/right margins, its height for rows and top/bottom margins)"##,
has_doc,
new_guide_layout
),
+44
View File
@@ -1242,6 +1242,50 @@ fn exported_selected_cube_round_trips_through_lookup_importer() {
assert_eq!(s.active().unwrap().doc.layers.len(), 3);
}
/// #4984: New Guide Layout takes lengths with units as typed in Photoshop's dialog, at the
/// document's resolution; percent is of the target's width or height, by axis.
#[test]
fn guide_layouts_take_units() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1200, "height": 600, "resolution": 300})).unwrap();
let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01);
// 1 cm at 300 ppi is 118.11 px; 0.25 in is 75 px.
s.execute("view.newGuideLayout", json!({"columns": 2, "gutter": "1 cm", "margin": "0.25in"})).unwrap();
let g = &doc(&s).guides;
let col = (1050.0 - 118.110_236) / 2.0;
assert!(close(&g.vertical, &[75.0, 1125.0, 75.0 + col, 75.0 + col + 118.110_236]), "{:?}", g.vertical);
assert!(close(&g.horizontal, &[75.0, 525.0]), "{:?}", g.horizontal);
// Percent of the width for columns, of the height for rows and top/bottom margins; points
// follow Units & Rulers (72 per inch: 36 pt is 150 px, centred like the columns).
s.execute(
"view.newGuideLayout",
json!({"columns": 1, "width": "25%", "centerColumns": true, "rows": 1, "height": "36 pt", "margin": ["10%", 0, "10%", 0], "clearExisting": true}),
)
.unwrap();
let g = &doc(&s).guides;
assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0]), "{:?}", g.vertical);
assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0]), "{:?}", g.horizontal);
// With selected artboards, percent is of each board, not of the document.
s.execute("layer.new.artboard", json!({"rect": [200, 100, 140, 40]})).unwrap();
s.execute(
"view.newGuideLayout",
json!({"columns": 1, "width": "50%", "centerColumns": true, "rows": 1, "height": "50%", "target": "selectedArtboards", "clearExisting": true}),
)
.unwrap();
let g = &doc(&s).guides;
// Clear Existing removes guides inside the board only; those outside it stay.
assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0, 235.0, 305.0]), "{:?}", g.vertical);
assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0, 110.0, 130.0]), "{:?}", g.horizontal);
for bad in [
json!({"columns": 2, "gutter": "1 furlong"}),
json!({"columns": 2, "gutter": "-1 cm"}),
json!({"columns": 2, "margin": [0, "x", 0, 0]}),
json!({"columns": 2, "gutter": true}),
] {
assert!(s.execute("view.newGuideLayout", bad.clone()).is_err(), "{bad}");
}
}
#[test]
fn guide_layouts() {
let mut s = session(100, 50, 8);
+55 -10
View File
@@ -4,6 +4,7 @@ use photocraft_doc::Guides;
use photocraft_geom::Rect;
use serde_json::Value;
use crate::prefs::Unit;
use crate::{EngineError, Result, Session};
const COMMAND: &str = "view.newGuideLayout";
@@ -14,10 +15,46 @@ fn bad(message: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
}
fn number(p: &Value, key: &str, default: f64) -> Result<f64> {
/// A length as given: a number of pixels, or text with a unit as typed in Photoshop's New Guide
/// Layout fields ("2 cm", "0.25in", "12 pt", "10%"), resolved against each target (#4984).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Length {
Px(f64),
In(f64, Unit),
}
impl Length {
/// Pixels at the document's `dpi`; percent is of `extent` (the target's width for column
/// sizes and left/right margins, its height for row sizes and top/bottom margins).
fn px(self, key: &str, dpi: f64, extent: f64, ppi: f64) -> Result<f64> {
let v = match self {
Length::Px(v) => v,
Length::In(v, unit) => unit.to_px(v, dpi, extent, ppi),
};
if v.is_finite() && v >= 0.0 { Ok(v) } else { Err(bad(format!("{key} must be a finite non-negative length"))) }
}
}
fn length_of(key: &str, v: &Value) -> Result<Length> {
let invalid = || bad(format!("{key} must be a non-negative number of pixels, or text with px, in, cm, mm, pt, pc or %"));
let length = match (v.as_f64(), v.as_str()) {
(Some(n), _) => Length::Px(n),
(None, Some(text)) => match Unit::parse_length(text).ok_or_else(invalid)? {
(n, None | Some(Unit::Pixels)) => Length::Px(n),
(n, Some(unit)) => Length::In(n, unit),
},
_ => return Err(invalid()),
};
match length {
Length::Px(n) | Length::In(n, _) if n.is_finite() && n >= 0.0 => Ok(length),
_ => Err(invalid()),
}
}
fn number(p: &Value, key: &str, default: Length) -> Result<Length> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).ok_or_else(|| bad(format!("{key} must be a finite non-negative number"))),
Some(v) => length_of(key, v),
}
}
@@ -110,9 +147,10 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let cols = count(p, "columns")?;
let rows = count(p, "rows")?;
let width = number(p, "width", 0.0)?;
let height = number(p, "height", 0.0)?;
let gap = number(p, "gutter", 0.0)?;
let zero = Length::Px(0.0);
let width = number(p, "width", zero)?;
let height = number(p, "height", zero)?;
let gap = number(p, "gutter", zero)?;
let row_gap = number(p, "rowGutter", gap)?;
let center = flag(p, "centerColumns")?;
let clear = flag(p, "clearExisting")?;
@@ -121,15 +159,16 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
if a.len() != 4 {
return Err(bad("margin must contain top, left, bottom and right"));
}
let mut m = [0.0; 4];
let mut m = [zero; 4];
for (out, value) in m.iter_mut().zip(a) {
*out = value.as_f64().filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| bad("margins must be finite non-negative numbers"))?;
*out = length_of("margin", value)?;
}
m
}
_ => [number(p, "margin", 0.0)?; 4],
_ => [number(p, "margin", zero)?; 4],
};
let [top, left, bottom, right] = margins;
let dpi = f64::from(st.doc.resolution_dpi);
let ppi = s.prefs().units_and_rulers.point_size.per_inch();
let bounds = targets(s, p)?;
// Bound the aggregate work as well as each count, including multi-artboard requests.
if (cols + rows + 4).saturating_mul(2).saturating_mul(bounds.len() as u64) > MAX_EDGES {
@@ -137,16 +176,22 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
}
let mut added = Guides::default();
for rect in &bounds {
let (across, down) = (f64::from(rect.width()), f64::from(rect.height()));
let [top, left, bottom, right] =
[(margins[0], down), (margins[1], across), (margins[2], down), (margins[3], across)].map(|(m, extent)| m.px("margin", dpi, extent, ppi));
let (top, left, bottom, right) = (top?, left?, bottom?, right?);
let (x0, y0, x1, y1) = (f64::from(rect.x0) + left, f64::from(rect.y0) + top, f64::from(rect.x1) - right, f64::from(rect.y1) - bottom);
if x0 >= x1 || y0 >= y1 {
return Err(bad("margins must leave space inside the target"));
}
if margins.iter().any(|x| *x != 0.0) {
if [top, left, bottom, right].iter().any(|x| *x != 0.0) {
push_unique(&mut added.vertical, x0)?;
push_unique(&mut added.vertical, x1)?;
push_unique(&mut added.horizontal, y0)?;
push_unique(&mut added.horizontal, y1)?;
}
let (width, gap) = (width.px("width", dpi, across, ppi)?, gap.px("gutter", dpi, across, ppi)?);
let (height, row_gap) = (height.px("height", dpi, down, ppi)?, row_gap.px("rowGutter", dpi, down, ppi)?);
axis(x0, x1, cols, width, gap, center, &mut added.vertical)?;
axis(y0, y1, rows, height, row_gap, center, &mut added.horizontal)?;
}