Compose: an empty paragraph's format comes from its own mark

Story::char_format_at returns the preceding run at a boundary (the format
typing takes), so a run ending at the empty paragraph's start gave it the
previous paragraph's mark format. Add Story::char_format_of (the run
containing the byte) and use it; regression test with differing marks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-07 21:56:43 -04:00
1 parent 60712dedb2
commit 593848bf17
3 files changed
+35 -1

No files matched your search

+1 -1
View File
@@ -650,7 +650,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
let spacing = spacing_for(&pp, base_size);
// An empty paragraph takes its height from its paragraph mark's character format.
let mark_chars = if glyphs.is_empty() {
std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_at(prange.start)))
std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_of(prange.start)))
} else {
std::borrow::Cow::Borrowed(&base_chars)
};
+19
View File
@@ -1044,3 +1044,22 @@ fn empty_paragraph_takes_its_marks_format() {
assert!((lines[1].baseline - lines[0].baseline - 8.0).abs() < 1e-6, "{} {}", lines[0].baseline, lines[1].baseline);
assert!((lines[2].baseline - lines[1].baseline - 8.0).abs() < 1e-6, "{} {}", lines[1].baseline, lines[2].baseline);
}
#[test]
fn empty_paragraph_uses_its_own_mark_not_the_previous_one() {
// "One\n" is 20 pt; the empty paragraph's mark (byte 4) and "Two" are 6 pt with 8 pt leading.
// The format run boundary sits exactly at the empty paragraph's start: its line must take the
// 8 pt leading of its own mark, not the previous paragraph's 20 pt (auto 24) mark.
let text = "One\n\nTwo";
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 200.0, 200.0), ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
st.format_chars(0..4, |f| f.over.size = Some(20.0));
st.format_chars(4..text.len(), |f| {
f.over.size = Some(6.0);
f.over.leading = Some(designcraft_doc::Leading::Points(8.0));
});
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 3);
assert!((lines[1].baseline - lines[0].baseline - 8.0).abs() < 1e-6, "{} {}", lines[0].baseline, lines[1].baseline);
}
+15
View File
@@ -192,6 +192,21 @@ impl Story {
self.chars.last().map_or(&NO_FORMAT, |r| &r.format)
}
/// Format of the character starting at byte `pos` (the run containing `pos`), unlike
/// [`Self::char_format_at`], which at a run boundary returns the format of the character
/// before the caret. Past the end, the last run's format.
pub fn char_format_of(&self, pos: usize) -> &CharFormat {
let mut start = 0usize;
for r in &self.chars {
let end = start.saturating_add(r.len);
if pos < end {
return &r.format;
}
start = end;
}
self.chars.last().map_or(&NO_FORMAT, |r| &r.format)
}
/// Iterate `(range, format)` of character runs.
pub fn runs(&self) -> impl Iterator<Item = (std::ops::Range<usize>, &CharFormat)> {
let mut start = 0;