diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 2779769bd..68fb5670b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1439,6 +1439,35 @@ fn vertical_frames_keep_the_ascent_first_baseline() { assert!((b - 20.0).abs() < 0.01, "{b}"); } +#[test] +fn a_visible_frame_stroke_insets_the_text() { + use designcraft_doc::{Stroke, StrokeAlign}; + let line = |stroke: Option<(StrokeAlign, &str)>| { + let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + if let Some((align, swatch)) = stroke { + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + } + it.text_frame_mut().unwrap().options.inset = [4.0; 4]; + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + (l.baseline, l.x0, l.x1) + }; + let black = designcraft_color::swatch::BLACK; + let (b, x0, x1) = line(None); + // The part of the 4 pt stroke inside the frame: half of it centred, all of it inside, none + // outside or without a colour; on the left and right as on the top. + for (stroke, d) in [ + ((StrokeAlign::Center, black), 2.0), + ((StrokeAlign::Inside, black), 4.0), + ((StrokeAlign::Outside, black), 0.0), + ((StrokeAlign::Inside, designcraft_color::swatch::NONE), 0.0), + ] { + let (sb, sx0, sx1) = line(Some(stroke)); + assert!((sb - (b + d)).abs() < 1e-6, "{stroke:?}: baseline {sb} vs {b}"); + assert!((sx0 - (x0 + d)).abs() < 1e-6 && (sx1 - (x1 - d)).abs() < 1e-6, "{stroke:?}: {sx0}..{sx1} vs {x0}..{x1}"); + } +} + #[test] fn vertical_scale_does_not_move_the_ascent_first_baseline() { let db = designcraft_fonts::FontDb::global(); diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index b06f4fe6f..dd690b093 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1141,13 +1141,29 @@ impl Item { b.inflate(w, w) } - /// The frame's text area (inner space) after inset. + /// The text frame's text area (inner space): inside the part of a visible stroke that lies + /// within the frame, then the inset, on each edge. pub fn text_area(&self) -> Rect { let r = self.inner_bounds(); - let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]); + let s = self.stroke_inside(); + let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]).map(|v| v + s); Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0])) } + /// How far a visible stroke reaches inside the path: half its weight centred, all of it inside, + /// none outside. + fn stroke_inside(&self) -> f64 { + let w = self.stroke.weight; + if self.stroke.is_none() || !w.is_finite() { + return 0.0; + } + match self.stroke.align { + StrokeAlign::Center => w / 2.0, + StrokeAlign::Inside => w, + StrokeAlign::Outside => 0.0, + } + } + /// Spread-space centre. pub fn center(&self) -> Point { self.bounds().center() @@ -1192,6 +1208,32 @@ mod tests { assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0)); } + #[test] + fn text_area_starts_inside_the_stroke() { + let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); + it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } }); + let unstroked = Rect::new(6.0, 5.0, 92.0, 43.0); + let stroked = |align: StrokeAlign, swatch: &str| { + let mut it = it.clone(); + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + it.text_area() + }; + let black = designcraft_color::swatch::BLACK; + // The part of the stroke inside the frame, on every edge, then the inset. + assert_eq!(stroked(StrokeAlign::Center, black), Rect::new(8.0, 7.0, 90.0, 41.0)); + assert_eq!(stroked(StrokeAlign::Inside, black), Rect::new(10.0, 9.0, 88.0, 39.0)); + assert_eq!(stroked(StrokeAlign::Outside, black), unstroked); + // A stroke without a colour doesn't count. + assert_eq!(stroked(StrokeAlign::Inside, designcraft_color::swatch::NONE), unstroked); + // Nor does a weight that isn't a number. + it.stroke = Stroke { weight: f64::NAN, ..Stroke::default() }; + assert_eq!(it.text_area(), unstroked); + // A stroke wider than the frame leaves no room for text. + it.stroke = Stroke { weight: 200.0, align: StrokeAlign::Inside, ..Stroke::default() }; + let a = it.text_area(); + assert!(a.width() == 0.0 && a.height() == 0.0, "{a:?}"); + } + #[test] fn group_bounds_union_children() { let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));