Fix text ink bounds overflow (#750)

* Fix text ink bounds overflow

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Validate type size and tracking parameters

Reject out-of-range text metrics at type command boundaries to avoid silently empty renders.

Co-authored-by: Copilot <[email protected]>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
This commit is contained in:
Alex GorevskiandCopilot authored and GitHub committed 2026-10-07 17:12:55 -04:00
1 parent 4a0a8d0794
commit e9142754a4
2 files changed
+92 -5

No files matched your search

+50 -1
View File
@@ -85,6 +85,10 @@ fn range_param(text: &str, p: &Value) -> (usize, usize) {
/// Photoshop's manual kerning range (1/1000 em).
pub const KERN_MIN: f64 = -1000.0;
pub const KERN_MAX: f64 = 10_000.0;
pub const TYPE_SIZE_MIN_PT: f64 = 0.1;
pub const TYPE_SIZE_MAX_PT: f64 = 1296.0;
pub const TRACKING_MIN: f64 = -1000.0;
pub const TRACKING_MAX: f64 = 10_000.0;
/// Validates the `kerning` key of character params: a number in [`KERN_MIN`]..=[`KERN_MAX`] or
/// `"metrics"`, `"optical"`, `"off"` (`"none"`, `"0"`).
@@ -100,6 +104,23 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> {
}
}
/// Validates text metrics that can otherwise exceed the rasterizer's supported range.
pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
if let Some(v) = p.get("size") {
match v.as_f64() {
Some(size) if size.is_finite() && (TYPE_SIZE_MIN_PT..=TYPE_SIZE_MAX_PT).contains(&size) => {}
_ => return Err(format!("size must be between {TYPE_SIZE_MIN_PT} and {TYPE_SIZE_MAX_PT} pt")),
}
}
if let Some(v) = p.get("tracking") {
match v.as_f64() {
Some(tracking) if tracking.is_finite() && (TRACKING_MIN..=TRACKING_MAX).contains(&tracking) => {}
_ => return Err(format!("tracking must be between {TRACKING_MIN} and {TRACKING_MAX} (1/1000 em)")),
}
}
Ok(())
}
/// Applies character-style keys from JSON. Returns true if any key was present.
pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
let mut any = false;
@@ -483,7 +504,7 @@ fn info(s: &Session, p: &Value) -> Result<Value> {
}))
}
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":pt,"color":"#rrggbb"|[r,g,b,a],"tracking":1/1000em,"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -496,6 +517,7 @@ pub fn specs() -> Vec<CommandSpec> {
enabled: has_doc,
journal: true,
run: |s, p| {
check_size_tracking(p).map_err(|m| bad("type.create", m))?;
let text = norm_text(p.get("text").and_then(Value::as_str).unwrap_or(""));
// Type › Save Default Type Styles sets the starting styles; the colour is always
// the foreground colour, as in Photoshop.
@@ -546,6 +568,7 @@ pub fn specs() -> Vec<CommandSpec> {
let id = layer_id(s, p)?;
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
check_kerning(p).map_err(|m| bad("type.edit", m))?;
check_size_tracking(p).map_err(|m| bad("type.edit", m))?;
if let Some(Value::Array(runs)) = p.get("runs") {
for (i, r) in runs.iter().enumerate() {
check_kerning(r).map_err(|m| bad("type.edit", m))?;
@@ -556,6 +579,7 @@ pub fn specs() -> Vec<CommandSpec> {
{
return Err(bad("type.edit", format!("runs[{i}]: `end` ({end}) is before `start` ({start})")));
}
check_size_tracking(r).map_err(|m| bad("type.edit", m))?;
}
}
let kern_pair = match p.get("kernPair") {
@@ -666,6 +690,7 @@ pub fn specs() -> Vec<CommandSpec> {
run: |s, p| {
let id = layer_id(s, p)?;
check_kerning(p).map_err(|m| bad("type.setStyle", m))?;
check_size_tracking(p).map_err(|m| bad("type.setStyle", m))?;
with_text_layer(s, p, "Set Type Style", |t, _, _| {
let (a, b) = range_param(&t.text, p);
let mut probe = CharStyle::default();
@@ -762,6 +787,30 @@ mod tests {
}
}
#[test]
fn absurd_type_size_and_tracking_are_rejected_as_bad_params() {
let mut s = session();
assert!(matches!(
s.execute("type.create", json!({"text": "abc", "size": 1e30})),
Err(EngineError::BadParams { cmd, .. }) if cmd == "type.create"
));
let id = s.execute("type.create", json!({"text": "abc", "size": 12})).unwrap()["layer"].as_u64().unwrap();
let before = text_layer(&s, id);
for params in [json!({"layer": id, "tracking": 1e30}), json!({"layer": id, "runs": [{"start": 0, "end": 3, "tracking": 1e30}]})] {
assert!(matches!(
s.execute("type.edit", params),
Err(EngineError::BadParams { cmd, .. }) if cmd == "type.edit"
));
assert_eq!(text_layer(&s, id).runs, before.runs);
}
assert!(matches!(
s.execute("type.setStyle", json!({"layer": id, "tracking": 1e30})),
Err(EngineError::BadParams { cmd, .. }) if cmd == "type.setStyle"
));
assert_eq!(text_layer(&s, id).runs, before.runs);
}
#[test]
fn create_renders_and_is_undoable() {
let mut s = session();
+42 -4
View File
@@ -148,14 +148,27 @@ pub fn ink_rect(layout: &TextLayout, transform: &Affine) -> Rect {
ink_rect_warped(layout, transform, None)
}
fn rect_from_bounds([x0, y0, x1, y1]: [f64; 4]) -> Rect {
if ![x0, y0, x1, y1].into_iter().all(f64::is_finite) {
return Rect::EMPTY;
}
let min = f64::from(i32::MIN);
let max = f64::from(i32::MAX);
let (x0, y0, x1, y1) =
((x0.floor() - 1.0).clamp(min, max), (y0.floor() - 1.0).clamp(min, max), (x1.ceil() + 1.0).clamp(min, max), (y1.ceil() + 1.0).clamp(min, max));
if x1 <= x0 || y1 <= y0 || x1 - x0 > MAX_PIXELS as f64 || y1 - y0 > MAX_PIXELS as f64 {
return Rect::EMPTY;
}
Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32)
}
/// [`ink_rect`] of warped text.
pub fn ink_rect_warped(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Rect {
let mut b = Bounds::default();
draw(layout, &Xform(transform.m), &mut b, None, warp);
match b.rect {
Some([x0, y0, x1, y1]) => Rect::new(x0.floor() as i32 - 1, y0.floor() as i32 - 1, x1.ceil() as i32 + 1, y1.ceil() as i32 + 1),
None => Rect::new(0, 0, 0, 0),
}
b.rect.map(rect_from_bounds).unwrap_or(Rect::EMPTY)
}
/// Rasterizes `layout` through `transform` (text space → document pixels). The result always
@@ -295,3 +308,28 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) ->
}
glyphs
}
#[cfg(test)]
mod tests {
use super::{MAX_PIXELS, rect_from_bounds};
use photocraft_geom::Rect;
#[test]
fn bounds_are_clipped_without_overflow_and_oversized_rectangles_rejected() {
let min = f64::from(i32::MIN);
let max = f64::from(i32::MAX);
let cases = [
([min, 0.0, min + 8.0, 8.0], Rect::new(i32::MIN, -1, i32::MIN + 9, 9)),
([max - 8.0, 0.0, max, 8.0], Rect::new(i32::MAX - 9, -1, i32::MAX, 9)),
([-f64::MAX, 0.0, f64::MAX, 8.0], Rect::EMPTY),
([0.0, 0.0, MAX_PIXELS as f64, 8.0], Rect::EMPTY),
([0.25, -3.25, 10.1, 8.8], Rect::new(-1, -5, 12, 10)),
];
for (bounds, expected) in cases {
let first = rect_from_bounds(bounds);
assert_eq!(first, expected, "bounds: {bounds:?}");
assert_eq!(rect_from_bounds(bounds), first, "bounds: {bounds:?}");
}
}
}