mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 10:46:50 -06:00
Lists: the first line after a bullet or number wraps at the column edge
In a hanging indent (a negative first-line indent), the tab after a bullet or number went to the next default tab stop, past the left indent: the first line's text started late and pushed the line past the column edge. The left indent is now a left tab stop of its own. A tab before it reaches it unless an explicit stop comes first, so the first line's text lines up with the lines below.
This commit is contained in:
@@ -615,7 +615,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
|
||||
let tab = |j: usize, x: f64, i: usize| {
|
||||
let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 };
|
||||
tab_advance(&pp.tabs, ind + x, gl.get(i + 1..).unwrap_or_default()).0
|
||||
tab_advance(&pp.tabs, pp.left_indent, ind + x, gl.get(i + 1..).unwrap_or_default()).0
|
||||
};
|
||||
breaker::greedy(&gl, &hy, &spacing, &width, &tab)
|
||||
} else if pp.balance_ragged && !spacing.justify {
|
||||
@@ -753,7 +753,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
let tab = |j: usize, x: f64, i: usize| {
|
||||
let x0 = slots.get(j).map_or(col.x0, |s| s.0);
|
||||
let ind = pp.left_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
|
||||
tab_advance(&pp.tabs, x0 + ind + x - col.x0, rest.get(i + 1..).unwrap_or_default()).0
|
||||
tab_advance(&pp.tabs, pp.left_indent, x0 + ind + x - col.x0, rest.get(i + 1..).unwrap_or_default()).0
|
||||
};
|
||||
breaker::greedy(rest, rest_h, &spacing, &width, &tab)
|
||||
} else if pp.balance_ragged && !spacing.justify {
|
||||
@@ -1541,13 +1541,18 @@ pub fn hj_severity(ratio: f64, min: f64, max: f64) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance of a tab that starts `abs` from the tab origin and is followed by `after`, and the tab
|
||||
/// stop it reaches (none: the next default stop, left aligned). Right, centre and character
|
||||
/// alignment look at the text up to the next tab or forced break.
|
||||
fn tab_advance<'a>(tabs: &'a [designcraft_doc::TabStop], abs: f64, after: &[Glyph]) -> (f64, Option<&'a designcraft_doc::TabStop>) {
|
||||
let stop = tabs.iter().find(|t| t.position > abs + 0.01);
|
||||
/// Advance of a tab that starts `abs` from the tab origin and is followed by `after`, and the
|
||||
/// explicit tab stop it reaches (none: the left indent or the next default stop, left aligned).
|
||||
/// Right, centre and character alignment look at the text up to the next tab or forced break.
|
||||
///
|
||||
/// The left indent is a left stop of its own: in a hanging indent, the tab after a bullet or
|
||||
/// number reaches it unless an explicit stop comes first.
|
||||
fn tab_advance<'a>(tabs: &'a [designcraft_doc::TabStop], left_indent: f64, abs: f64, after: &[Glyph]) -> (f64, Option<&'a designcraft_doc::TabStop>) {
|
||||
let indent_ahead = left_indent > abs + 0.01;
|
||||
let stop = tabs.iter().find(|t| t.position > abs + 0.01).filter(|t| !indent_ahead || t.position <= left_indent);
|
||||
let (pos, align) = match stop {
|
||||
Some(t) => (t.position, t.align),
|
||||
None if indent_ahead => (left_indent, TabAlign::Left),
|
||||
None => (((abs / DEFAULT_TAB).floor() + 1.0) * DEFAULT_TAB, TabAlign::Left),
|
||||
};
|
||||
let seg = after.iter().take_while(|g| g.ch != '\t' && !breaker::is_forced(g.ch));
|
||||
@@ -1603,7 +1608,7 @@ fn layout_line(
|
||||
let mut leaders: Vec<(usize, String)> = Vec::new();
|
||||
while i < line.len() {
|
||||
if line[i].ch == '\t' {
|
||||
let (w, stop) = tab_advance(&pp.tabs, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default());
|
||||
let (w, stop) = tab_advance(&pp.tabs, pp.left_indent, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default());
|
||||
if let Some(t) = stop
|
||||
&& !t.leader.is_empty()
|
||||
{
|
||||
|
||||
@@ -1917,3 +1917,59 @@ fn vertical_lines_fit_the_em_box() {
|
||||
assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_first_line_wraps_at_the_column_edge_after_its_label() {
|
||||
use designcraft_doc::{ListType, TabStop};
|
||||
// A hanging indent (left 18, first line −18): the label sits at the column start, a tab
|
||||
// after it reaches the left indent (an implicit stop when no explicit stop comes first), and
|
||||
// the first line wraps at the same right edge as the others.
|
||||
let right_edge = |l: &Line, source: &str| {
|
||||
let space = |g: &&PlacedGlyph| g.len > 0 && source.get(g.byte..).and_then(|s| s.chars().next()).is_some_and(char::is_whitespace);
|
||||
l.glyphs.iter().filter(|g| g.visible && !space(g)).map(|g| g.x + g.adv).fold(0.0, f64::max)
|
||||
};
|
||||
let text_start = |l: &Line| l.glyphs.iter().find(|g| g.len > 0 && g.visible).map_or(f64::NAN, |g| g.x);
|
||||
let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() };
|
||||
let text = format!("{LOREM}\n{LOREM}");
|
||||
// (list, separator, explicit tab stops, where line 0's text starts past the column start:
|
||||
// None = right after the label, wherever that is).
|
||||
let cases = [
|
||||
(ListType::Bullets, "\t", None, Some(18.0)),
|
||||
(ListType::Numbers, "\t", None, Some(18.0)),
|
||||
(ListType::Numbers, "\t", Some(vec![stop(12.0)]), Some(12.0)),
|
||||
(ListType::Numbers, "\t", Some(vec![stop(30.0)]), Some(18.0)),
|
||||
(ListType::Bullets, " ", None, None),
|
||||
(ListType::Numbers, "\u{2003}", None, None),
|
||||
];
|
||||
for (list, sep, tabs, first_at) in cases {
|
||||
let para = ParaAttrs {
|
||||
list_type: Some(list),
|
||||
list_separator: Some(sep.into()),
|
||||
left_indent: Some(18.0),
|
||||
first_line_indent: Some(-18.0),
|
||||
tabs: tabs.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let (d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 400.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let col = cs.frames[0].columns[0];
|
||||
let lines = &cs.frames[0].lines;
|
||||
let what = format!("{list:?} {sep:?} {tabs:?}");
|
||||
assert!(lines.len() >= 6, "{what}: {} lines", lines.len());
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let right = right_edge(l, &text);
|
||||
assert!(right <= col.x1 + 0.01, "{what}: line {i} ends at {right}, past the column's {}", col.x1);
|
||||
let start = text_start(l) - col.x0;
|
||||
if !l.first_in_para {
|
||||
assert!((start - 18.0).abs() < 0.01, "{what}: line {i} text starts at {start}, not the left indent");
|
||||
} else if let Some(at) = first_at {
|
||||
assert!((start - at).abs() < 0.01, "{what}: line {i} text starts at {start}, not {at}");
|
||||
} else {
|
||||
assert!(start > 0.0, "{what}: line {i} text starts at {start}");
|
||||
}
|
||||
if l.first_in_para {
|
||||
assert!(l.glyphs.first().is_some_and(|g| g.len == 0 && (g.x - col.x0).abs() < 0.01), "{what}: the label leads line {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user