Text: a line with a tab wraps at the column edge (#282)

* Footnotes: the first line wraps at the column edge after the number

Line breaking gave tabs no width: a tab only reached its stop when the
line was laid out, so a line holding a tab ran past the measure by the
tab's advance. A footnote whose separator is a tab overflowed its first
line by the gap between the number and the tab stop, and body lines
with tabs did the same.

The greedy breaker now asks for each tab's advance at its position on
the line, computed by the same tab_advance helper line layout uses.

* 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.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aiongg 2026-10-10 03:03:25 -04:00 committed by GitHub
parent 60ad82497b
commit 99af9f513f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 153 additions and 28 deletions

View File

@ -390,7 +390,7 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
return b;
}
}
greedy(glyphs, hyph_after, sp, width)
greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0)
}
/// Upper bound on the hyphen-count states tracked per breakpoint.
@ -628,7 +628,16 @@ fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize {
}
/// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs).
pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
///
/// `tab(line, x, i)` is the advance of the tab glyph `i` when it starts `x` from the start of
/// line `line`: tabs have no width until they reach their stop, which depends on where they sit.
pub fn greedy(
glyphs: &[Glyph],
hyph_after: &[bool],
sp: &Spacing,
width: &dyn Fn(usize) -> f64,
tab: &dyn Fn(usize, f64, usize) -> f64,
) -> Vec<Break> {
let n = glyphs.len();
let mut out = Vec::new();
let mut start = 0;
@ -664,7 +673,8 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
continue;
}
let hang_r = right_hang(g, sp);
if x + g.adv - hang_r > w + shrink
let adv = if g.ch == '\t' { tab(line, x, i) } else { g.adv };
if x + adv - hang_r > w + shrink
&& i > start
&& (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break))
{
@ -679,7 +689,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
});
break;
}
x += g.adv;
x += adv;
if sp.justify {
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
}

View File

@ -657,7 +657,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
apply_desired_spacing(&mut gl, &pp);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
breaker::greedy(&gl, &hy, &spacing, &width)
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, 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 {
breaker::balanced(&gl, &hy, &spacing, &width)
} else {
@ -836,7 +840,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let rest = &glyphs[g0..];
let rest_h = &hyph_after[g0..];
let breaks: Vec<Break> = if pp.composer == Composer::SingleLine || has_tabs || rest.len() > 4000 {
breaker::greedy(rest, rest_h, &spacing, &width)
// Where each line starts relative to the tab origin, as `layout_line` places it.
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, 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 {
breaker::balanced(rest, rest_h, &spacing, &width)
} else {
@ -1866,6 +1876,34 @@ 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
/// 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));
let gap = pos - abs;
let w = match align {
TabAlign::Left => gap,
TabAlign::Right => gap - seg.map(|g| g.adv).sum::<f64>(),
TabAlign::Center => gap - seg.map(|g| g.adv).sum::<f64>() / 2.0,
TabAlign::Char => {
let ch = stop.and_then(|t| t.align_on.chars().next()).unwrap_or('.');
gap - seg.take_while(|g| g.ch != ch).map(|g| g.adv).sum::<f64>()
}
};
(w.max(0.0), stop)
}
/// Position glyphs `s..e` within `[x0, x1]`; returns (glyphs, end x, word-space ratio).
///
/// Justified lines distribute the difference to the measure in priority order: word spaces up to
@ -1905,32 +1943,13 @@ fn layout_line(
let mut leaders: Vec<(usize, String)> = Vec::new();
while i < line.len() {
if line[i].ch == '\t' {
let abs = x0 + x - tab_origin;
let stop = pp.tabs.iter().find(|t| t.position > abs + 0.01).cloned();
if let Some(t) = &stop
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()
{
leaders.push((i, t.leader.clone()));
}
let (pos, align) = match &stop {
Some(t) => (t.position, t.align),
None => (((abs / DEFAULT_TAB).floor() + 1.0) * DEFAULT_TAB, TabAlign::Left),
};
// Width of the text after this tab up to the next tab / line end.
let seg_end = line[i + 1..].iter().position(|g| g.ch == '\t').map_or(line.len(), |p| i + 1 + p);
let seg_w: f64 = line[i + 1..seg_end].iter().map(|g| g.adv).sum();
let target = pos + tab_origin - x0;
let w = match align {
TabAlign::Left => target - x,
TabAlign::Right => target - x - seg_w,
TabAlign::Center => target - x - seg_w / 2.0,
TabAlign::Char => {
let ch = stop.as_ref().and_then(|t| t.align_on.chars().next()).unwrap_or('.');
let before: f64 = line[i + 1..seg_end].iter().take_while(|g| g.ch != ch).map(|g| g.adv).sum();
target - x - before
}
};
line[i].adv = w.max(0.0);
line[i].adv = w;
} else if line[i].ch == story::RIGHT_INDENT_TAB {
let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum();
line[i].adv = (measure - x - rest).max(0.0);

View File

@ -815,6 +815,46 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() {
assert!(note_caret(&cs, n.id, 0).is_some());
}
#[test]
fn footnote_first_line_wraps_at_the_column_edge_after_its_number() {
// InDesign sets the footnote number and separator as part of the first line: that line
// wraps at the same right edge as the others, whatever the separator. A tab separator
// reaches the footnote style's tab stop.
let tab_at = |position: f64| {
let stop = designcraft_doc::TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() };
ParaAttrs { tabs: Some(vec![stop]), ..Default::default() }
};
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 cases =
[("\t", tab_at(100.0)), ("\t", ParaAttrs::default()), (" ", ParaAttrs::default()), (".\u{2003}\u{2003}\u{2003}", ParaAttrs::default())];
for (sep, para) in cases {
let (mut d, sid, _) = doc_with("Short text.", Rect::new(36.0, 36.0, 300.0, 400.0), ParaAttrs::default());
d.footnote_options.separator = sep.into();
d.footnote_options.start_at = 1234;
d.story_mut(sid).unwrap().insert_note(5, &LOREM.repeat(2), ParaFormat { para, ..Default::default() });
let cs = compose_story(&d, sid, &ComposeOptions::default());
let n = &cs.frames[0].notes[0];
let width = n.rect.width();
let lines = &n.text.frames[0].lines;
assert!(lines.len() >= 3, "{sep:?}: {} lines", lines.len());
for (i, l) in lines.iter().enumerate() {
let right = right_edge(l, &n.source);
assert!(right <= width + 0.01, "{sep:?}: line {i} ends at {right}, past the column's {width}");
}
assert!(lines[0].glyphs.first().is_some_and(|g| g.len == 0 && g.x < 1.0), "{sep:?}: the number leads line 0");
}
// The same holds for a tab in body text.
let text = format!("Term\t{LOREM}");
let (d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 400.0), tab_at(100.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
let edge = cs.frames[0].columns[0].x1;
assert!(right_edge(l, &text) <= edge + 0.01, "body line 0 ends at {}, past {edge}", right_edge(l, &text));
}
#[test]
fn footnote_line_at_column_top_still_sets() {
// A note taller than the frame can't push its reference line forever.
@ -1937,6 +1977,62 @@ fn vertical_lines_fit_the_em_box() {
}
}
#[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}");
}
}
}
}
fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, StoryId)) -> (Document, ItemId, Vec<Rect>) {
let (mut d, sid, fid) = doc_with(text, rect, ParaAttrs::default());
{