diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ba483e133..ac196dc6a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2217,6 +2217,8 @@ fn layout_line( let mut x = 0.0; let mut i = 0; let mut leaders: Vec<(usize, String)> = Vec::new(); + // The line's last tab and whether it is a left tab (only text after a left tab justifies). + let mut last_tab: Option<(usize, bool)> = None; 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()); @@ -2226,9 +2228,11 @@ fn layout_line( leaders.push((i, t.leader.clone())); } line[i].adv = w; + last_tab = Some((i, stop.is_none_or(|t| t.align == TabAlign::Left))); } 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); + last_tab = Some((i, false)); } x += line[i].adv; i += 1; @@ -2253,7 +2257,11 @@ fn layout_line( } let natural: f64 = line.iter().map(|g| g.adv).sum(); let mut extra = measure - natural; - let spaces: Vec = line.iter().enumerate().filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect(); + // Justification adjusts only the text after the last tab (InDesign keeps tab stops aligned); + // a line whose last tab is a right, centre, character or right-indent tab is not justified. + let seg = last_tab.map_or(0, |(i, _)| i + 1); + let seg_justifies = last_tab.is_none_or(|(_, left)| left); + let spaces: Vec = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect(); let align = match pp.align { Align::TowardsSpine => { if left_page { @@ -2271,10 +2279,10 @@ fn layout_line( } a => a, }; - let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && !has_tab; + let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && seg_justifies; // A justified paragraph's last line may have been composed with shrunk spaces: shrink it too. let squeeze_last = !justify_this - && !has_tab + && seg_justifies && extra < 0.0 && (align.is_justified() || line.iter().any(|g| g.moji.shrink > 0.0 || mojikumi::start_elastic(g, false, true)[1] > 0.0)); // Extra advance per glyph (word spaces and letter gaps) and horizontal scale per glyph. @@ -2284,7 +2292,7 @@ fn layout_line( // Kashidas: in justified Arabic, the joins of words take the extra length first. let mut kashidas: Vec<(usize, f64)> = Vec::new(); if justify_this && extra > 0.0 && pp.kashidas && pp.arabic_justification != "DefaultJustification" { - let points = kashida_points(&line); + let points: Vec = kashida_points(&line).into_iter().filter(|&i| i >= seg).collect(); if !points.is_empty() { let total = extra.min(points.iter().map(|&i| line[i].size * 1.5).sum()); let per = total / points.len() as f64; @@ -2299,8 +2307,11 @@ fn layout_line( extra = mojikumi::distribute(&mut line, extra, &mut add); } if (justify_this || squeeze_last) && !spaces.is_empty() { - distribute(&line, &spaces, extra, sp, &mut add, &mut scale); - } else if justify_this && spaces.is_empty() && line.len() > 1 && !last { + let rebased: Vec = spaces.iter().map(|&i| i - seg).collect(); + let (seg_line, seg_add, seg_scale) = + (line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut [])); + distribute(seg_line, &rebased, extra, sp, seg_add, seg_scale); + } else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last { // Single word: Single Word Justification. match pp.single_word_justify { Align::FullyJustified => { @@ -2308,6 +2319,7 @@ fn layout_line( .iter() .enumerate() .take(line.len() - 1) + .skip(seg) .filter(|(_, g)| !g.moji.active && (!g.locked_advance || g.break_after != Some(false))) .map(|(i, _)| i) .collect(); @@ -2318,8 +2330,14 @@ fn layout_line( } } } - Align::Center => offset = extra / 2.0, - Align::Right => offset = extra, + // After a tab the word moves by widening the tab, so text before it stays put. + Align::Center | Align::Right => { + let shift = if pp.single_word_justify == Align::Center { extra / 2.0 } else { extra }; + match seg.checked_sub(1).and_then(|t| add.get_mut(t)) { + Some(tab) => *tab += shift, + None => offset = shift, + } + } _ => {} } } else { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 52be3578a..2800ed33d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3962,3 +3962,43 @@ fn column_rules_break_around_a_spanning_paragraph_between_bands() { assert!(here.iter().any(|r| (r.y0 - bottom).abs() < 1e-9), "rule resumes below the heading at {x}: {here:?}"); } } + +#[test] +fn justified_line_with_a_tab_justifies_the_text_after_its_last_tab() { + // InDesign justifies only the text after a line's last (left) tab; tab stops stay aligned. + let stop = |align| designcraft_doc::TabStop { position: 100.0, align, leader: String::new(), align_on: String::new() }; + let text = "Name\t42 and some words\u{2028}more"; + let x_of = |l: &Line, byte: usize| l.glyphs.iter().find(|g| g.byte == byte && g.len > 0).map(|g| g.x).unwrap(); + let left = ParaAttrs { align: Some(Align::LeftJustified), tabs: Some(vec![stop(TabAlign::Left)]), ..Default::default() }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), left.clone()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = all_lines(&cs)[0]; + assert!((l.end_x - l.x1).abs() < 0.6, "line with a left tab ends at {} not {}", l.end_x, l.x1); + assert!((x_of(l, 5) - 100.0).abs() < 0.5, "text after the tab starts at the stop: {}", x_of(l, 5)); + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs { align: Some(Align::Left), ..left }); + let ragged = compose_story(&d, sid, &ComposeOptions::default()); + let r = all_lines(&ragged)[0]; + for byte in 0..4 { + assert!((x_of(l, byte) - x_of(r, byte)).abs() < 1e-6, "text before the tab keeps its natural position"); + } + + // After a right tab the line stays as set. + let right = ParaAttrs { align: Some(Align::LeftJustified), tabs: Some(vec![stop(TabAlign::Right)]), ..Default::default() }; + let (d, sid, _) = doc_with("Name\t42\u{2028}more", Rect::new(0.0, 0.0, 300.0, 100.0), right); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = all_lines(&cs)[0]; + assert!((l.end_x - 100.0).abs() < 0.5, "right-tab line ends at its stop: {}", l.end_x); + + // A footnote's number and tab separator: its first line is justified like the others. + let (mut d, sid, fid) = doc_with("Body text.", Rect::new(0.0, 0.0, 200.0, 400.0), ParaAttrs::default()); + let note = ParaFormat { para: ParaAttrs { align: Some(Align::LeftJustified), ..Default::default() }, ..Default::default() }; + d.story_mut(sid).unwrap().insert_note(4, LOREM, note); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let n = &cs.frame(fid).unwrap().notes[0]; + let lines = all_lines(&n.text); + assert!(lines.len() > 2); + for l in &lines[..lines.len() - 1] { + assert!((l.end_x - l.x1).abs() < 0.6, "footnote line ends at {} not {}", l.end_x, l.x1); + } + assert!((x_of(lines[0], 0) - 36.0).abs() < 0.5, "note text starts at the default tab stop: {}", x_of(lines[0], 0)); +}