From 0474a8b5eca1634ea73b31a95465cf1ce57eb260 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 19:22:22 +0000 Subject: [PATCH] Compose: jidori runs keep their width when their spaces could stretch; tests for nonbreaking-space lines --- crates/compose/src/breaker.rs | 5 ++-- crates/compose/src/lib.rs | 3 ++- crates/compose/src/shape.rs | 3 ++- crates/compose/src/tests.rs | 43 +++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 9688c1f0d..6b0654388 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -92,9 +92,10 @@ impl Spacing { [0.0, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)], ) } - /// Word-space stretch and shrink of space glyph `g` (see [`Glyph::is_word_space`]). + /// Word-space stretch and shrink of space glyph `g` (see [`Glyph::is_word_space`]); none + /// inside a jidori run, whose width is fitted. fn space_elastic(&self, g: &Glyph) -> (f64, f64) { - if !g.is_word_space() { + if !g.is_word_space() || g.locked_advance { return (0.0, 0.0); } (g.space * (self.word_max - self.word_desired).max(0.0), g.space * (self.word_desired - self.word_min).max(0.0)) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 30c0a0fab..3ef6a78f8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2260,7 +2260,8 @@ fn layout_line( // 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_justify_space()).map(|(i, _)| i).collect(); + // Spaces inside a jidori run keep the run's fitted width, as in the breaker. + let spaces: Vec = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_justify_space() && !g.locked_advance).map(|(i, _)| i).collect(); let align = match pp.align { Align::TowardsSpine => { if left_page { diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 229368266..c756d3be7 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -71,7 +71,8 @@ impl Glyph { matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}') || (self.ch == '\u{3000}' && self.ideographic_space_elastic) } /// A space whose width justification and word spacing vary: U+0020, the nonbreaking space - /// U+00A0 and an elastic ideographic space. Fixed-width spaces (U+2002–U+200A, U+202F) keep theirs. + /// U+00A0 and an elastic ideographic space. Fixed-width spaces (U+2002–U+200A, U+202F) get no + /// word-space stretch or shrink. pub fn is_word_space(&self) -> bool { matches!(self.ch, ' ' | NBSP) || (self.ch == '\u{3000}' && self.ideographic_space_elastic) } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ef61b4abc..b434e3bcd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -386,6 +386,49 @@ fn nonbreaking_space_stretches_and_fixed_width_one_does_not() { } } +#[test] +fn jidori_run_keeps_its_width_on_a_justified_line() { + fn first_line(text: &str, width: f64, para: ParaAttrs, run: std::ops::Range) -> Line { + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 1000.0), para); + d.story_mut(sid).unwrap().format_chars(run, |f| f.over.jidori = Some(6)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs)[0].clone() + } + for sp in ['\u{A0}', ' '] { + let text = format!("aa{sp}aa bbbb cccc dddd eeee ffff gggg hhhh"); + let run = 0..4 + sp.len_utf8(); + let run_width = |l: &Line| l.glyphs.iter().filter(|g| run.contains(&g.byte)).map(|g| g.adv).sum::(); + let ragged = first_line(&text, 1000.0, ParaAttrs::default(), run.clone()); + let gap = ragged.glyphs.iter().find(|g| g.byte == text.find(" dddd").unwrap()).unwrap(); + let width = gap.x - ragged.x0 + gap.adv + 2.0 * ragged.x0; + let para = ParaAttrs { align: Some(Align::LeftJustified), word_space_max: Some(4.0), ..Default::default() }; + let line = first_line(&text, width, para, run.clone()); + assert_eq!(text[line.range.clone()].trim_end(), format!("aa{sp}aa bbbb cccc")); + assert!((line.end_x - line.x1).abs() < 0.1, "{sp:?}: line ends at {} not {}", line.end_x, line.x1); + assert!((run_width(&line) - run_width(&ragged)).abs() < 1e-6, "{sp:?}: run is {} not {}", run_width(&line), run_width(&ragged)); + } +} + +#[test] +fn nonbreaking_space_alone_fills_a_justified_line() { + let text = "10\u{A0}km 1234"; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ragged = all_lines(&cs)[0]; + let natural = ragged.glyphs.iter().map(|g| (g.byte, g.adv)).collect::>(); + let m = ragged.glyphs.iter().find(|g| g.byte == 5).unwrap(); + let width = m.x - ragged.x0 + m.adv + 15.0 + 2.0 * ragged.x0; + let line = justified_first_line(text, width, None); + assert_eq!(text[line.range.clone()].trim_end(), "10\u{A0}km"); + for g in line.glyphs.iter().filter(|g| g.byte < 6) { + if g.byte == 2 { + assert!((g.adv - natural[&2] - 15.0).abs() < 0.1, "U+00A0 is {}", g.adv); + } else { + assert!((g.adv - natural[&g.byte]).abs() < 1e-6, "glyph at {} is {}", g.byte, g.adv); + } + } +} + /// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame. fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec) { let mut parts: Vec = (0..fillers).map(|k| format!("Filler {k}")).collect();