Text: a frame's visible stroke insets its text

A text frame's text area starts at the inner edge of its stroke: the
frame edge, then the part of the stroke inside the frame, then the
inset, on all four edges. A centred stroke takes half its weight, an
inside stroke all of it and an outside stroke none. A stroke without a
colour (or with no weight) doesn't count.

Composition, hit testing and vertical frames all take the text area
from the frame, so they agree. Graphics frames, wraps and the frame's
bounds are unchanged, and type on a path, which has no inset, ignores
the stroke as well.
This commit is contained in:
aiongg
2026-10-07 22:55:43 -04:00
parent 93287b0b10
commit bb608dc03f
2 changed files with 73 additions and 2 deletions
+29
View File
@@ -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();
+44 -2
View File
@@ -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)));