mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
4a0a8d0794
commit
e9142754a4
2 files changed
+92
-5
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user