diff --git a/parley/src/layout/cluster.rs b/parley/src/layout/cluster.rs index 25f0b3bec..c07299cae 100644 --- a/parley/src/layout/cluster.rs +++ b/parley/src/layout/cluster.rs @@ -438,10 +438,14 @@ impl<'a, B: Brush> Cluster<'a, B> { None } - /// Returns the visual offset of this cluster along direction of text flow. + /// Returns the visual inline-axis offset of this cluster from the line's minimum coordinate. /// - /// This cost of this function is roughly linear in the number of clusters - /// on the containing line. + /// The offset includes [`crate::LineMetrics::offset`], but not + /// [`crate::LineMetrics::inline_min_coord`]. + /// + /// The cost of this function is roughly linear in the number of clusters + /// on the containing line. Use [`Line::positioned_visual_clusters`] to obtain + /// all cluster offsets in a single pass. pub fn visual_offset(&self) -> Option { let line = self.line(); let mut offset = line.metrics().offset; diff --git a/parley/src/layout/line.rs b/parley/src/layout/line.rs index 20057265f..444aa04a4 100644 --- a/parley/src/layout/line.rs +++ b/parley/src/layout/line.rs @@ -1,12 +1,12 @@ // Copyright 2021 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use crate::layout::Style; use crate::layout::data::BreakReason; use crate::layout::data::{LayoutItemKind, LineData}; use crate::layout::layout::Layout; use crate::layout::run::Run; use crate::layout::spacing::EffectiveSpacing; +use crate::layout::{Cluster, Style}; use crate::style::Brush; use crate::{BaselineShift, InlineBox, InlineBoxKind}; @@ -126,6 +126,44 @@ impl<'a, B: Brush> Line<'a, B> { self.items_nonpositioned().filter_map(|item| item.run()) } + /// Returns clusters in visual left-to-right order with their inline-axis offsets. + /// + /// Each offset matches [`Cluster::visual_offset`]: it includes [`LineMetrics::offset`], + /// but not [`LineMetrics::inline_min_coord`]. Add the latter to get the cluster's + /// inline-axis coordinate within the layout. + /// + /// Positions are accumulated in a single pass over the line's items and clusters, + /// without allocating. The offsets reflect the current line breaking, alignment, + /// spacing and visual item order. + pub fn positioned_visual_clusters( + &self, + ) -> impl Iterator, f32)> + Clone + use<'a, B> { + let mut run_offset = self.metrics().offset; + self.items_nonpositioned() + .filter_map(move |item| match item { + LineItem::InlineBox(inline_box) => { + run_offset += inline_box.width; + None + } + LineItem::Run(run) => { + let cluster_offset = run_offset; + // Keep these sums separate to match visual_offset()'s rounding: + // preceding runs contribute run.advance(), not their cluster sum. + run_offset += run.advance(); + // Accumulate advances even when iterator adaptors skip clusters. + Some( + run.visual_clusters() + .scan(cluster_offset, |offset, cluster| { + let position = *offset; + *offset += cluster.advance(); + Some((cluster, position)) + }), + ) + } + }) + .flatten() + } + /// Returns an iterator over the non-glyph runs and inline boxes for the line. pub(crate) fn items_nonpositioned( &self, @@ -355,7 +393,7 @@ impl<'a, B: Brush> GlyphRun<'a, B> { /// [`GlyphRunIter::next`] calls. /// /// This walks the run's shaped data ([`Atom`]s) directly rather than materialising -/// [`Cluster`](crate::Cluster)s and their composed glyph iterators. It computes the same glyph +/// [`Cluster`]s and their composed glyph iterators. It computes the same glyph /// counts and advances as [`Run::glyphs_in`] over the same atoms. #[derive(Clone)] struct AtomIter<'a> { diff --git a/parley/src/tests/mod.rs b/parley/src/tests/mod.rs index 8174eae7d..e1180f1f2 100644 --- a/parley/src/tests/mod.rs +++ b/parley/src/tests/mod.rs @@ -5,5 +5,6 @@ mod test_analysis; mod test_builders; mod test_inline_bidi; mod test_inline_edge_placement; +mod test_positioned_clusters; mod test_style_metrics; mod utils; diff --git a/parley/src/tests/test_positioned_clusters.rs b/parley/src/tests/test_positioned_clusters.rs new file mode 100644 index 000000000..cafa07c9f --- /dev/null +++ b/parley/src/tests/test_positioned_clusters.rs @@ -0,0 +1,249 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use super::utils::fonts::{FONT_FAMILY_LIST, create_font_context}; +use crate::{ + Alignment, AlignmentOptions, BaseDirection, FontFamily, IndentOptions, InlineBox, + InlineBoxKind, Layout, LayoutContext, TextStyle, VerticalAlign, +}; + +fn inline_box(id: u64, index: usize, width: f32) -> InlineBox { + InlineBox { + id, + index, + kind: InlineBoxKind::InFlow, + width, + height: 10.0, + baseline: None, + vertical_align: VerticalAlign::BASELINE, + } +} + +fn assert_positions_match(layout: &Layout<()>) { + for line in layout.lines() { + let mut positioned = line.positioned_visual_clusters(); + for expected in line.runs().flat_map(|run| run.visual_clusters()) { + let (cluster, offset) = positioned.next().expect("every cluster must be positioned"); + assert_eq!(cluster.path(), expected.path(), "visual order must match"); + assert_eq!( + offset.to_bits(), + expected.visual_offset().unwrap().to_bits(), + "offset must match bit for bit at {:?}", + cluster.path(), + ); + } + assert!(positioned.next().is_none(), "no extra clusters"); + assert!(positioned.next().is_none(), "iteration stays exhausted"); + } +} + +fn position_key((cluster, offset): (crate::Cluster<'_, ()>, f32)) -> (crate::ClusterPath, u32) { + (cluster.path(), offset.to_bits()) +} + +#[test] +fn positions_match_after_rebreaking_and_realigning_styled_bidi_text() { + let text = "office a\u{301} العربية AV alpha beta gamma delta\nאבג end "; + let split = text.find('ا').unwrap(); + let mut fonts = create_font_context(); + let mut context = LayoutContext::<()>::new(); + for direction in [BaseDirection::Ltr, BaseDirection::Rtl] { + let mut builder = context.style_run_builder(&mut fonts, text, 1.0, false); + builder.set_base_direction(direction); + let first = builder.push_style(TextStyle { + font_family: FontFamily::from(FONT_FAMILY_LIST), + letter_spacing: 0.75, + word_spacing: 2.25, + ..TextStyle::default() + }); + let second = builder.push_style(TextStyle { + font_family: FontFamily::from(FONT_FAMILY_LIST), + font_size: 20.0, + letter_spacing: -0.25, + ..TextStyle::default() + }); + builder.push_style_run(first, ..split); + builder.push_style_run(second, split..); + for (id, (index, width)) in [ + (0, 8.125), + (split, 11.75), + (split, 0.0), + (split, 5.5), + (text.len(), 6.625), + ] + .into_iter() + .enumerate() + { + builder.push_inline_box(inline_box(id as u64, index, width)); + } + let mut layout = builder.build(text); + layout.set_text_indent(9.25, IndentOptions::default()); + for width in [Some(120.0), None, Some(83.0), Some(120.0)] { + layout.break_all_lines(width); + for alignment in [ + Alignment::Justify, + Alignment::End, + Alignment::Center, + Alignment::Left, + Alignment::Right, + Alignment::Start, + ] { + layout.align(alignment, AlignmentOptions::default()); + assert_positions_match(&layout); + } + } + } +} + +#[test] +fn positions_follow_visual_item_reordering_and_iterator_clones_are_independent() { + let text = "office العربية end"; + let mut fonts = create_font_context(); + let mut context = LayoutContext::<()>::new(); + let mut builder = context.ranged_builder(&mut fonts, text, 1.0, false); + builder.push_default(FontFamily::from(FONT_FAMILY_LIST)); + builder.push_inline_box(inline_box(0, 0, 7.125)); + builder.push_inline_box(inline_box(1, 7, 9.375)); + let mut layout = builder.build(text); + let mut breaker = layout.break_lines(); + breaker.state_mut().set_line_x(13.25); + breaker.break_remaining(f32::MAX); + let line = layout.get(0).unwrap(); + assert_eq!( + line.metrics().inline_min_coord, + 13.25, + "exercise a nonzero line origin" + ); + let boxes = line + .inline_box_indices() + .map(|(index, _)| index) + .collect::>(); + // Hosts may move decoration boxes, but text runs must keep their relative order. + let order = (0..line.len()) + .filter(|index| !boxes.contains(index)) + .chain(boxes.iter().rev().copied()) + .collect::>(); + layout.reorder_line_items(0, &order); + assert_positions_match(&layout); + + let line = layout.get(0).unwrap(); + let expected = line + .runs() + .flat_map(|run| run.visual_clusters()) + .map(|cluster| position_key((cluster, cluster.visual_offset().unwrap()))) + .collect::>(); + let positions = line.positioned_visual_clusters(); + for index in 0..expected.len() { + let mut skipped = positions.clone(); + assert_eq!( + skipped.nth(index).map(position_key), + Some(expected[index]), + "nth must accumulate skipped advances at {index}" + ); + assert_eq!( + skipped.map(position_key).collect::>(), + expected[index + 1..], + "iteration after nth at {index}" + ); + assert_eq!( + positions + .clone() + .skip(index) + .map(position_key) + .collect::>(), + expected[index..], + "skip at {index}" + ); + } + assert_eq!( + positions.last().map(position_key), + expected.last().copied(), + "last must accumulate preceding advances" + ); + + // The iterator borrows the layout, rather than the temporary Line handle. + let mut positioned = layout.get(0).unwrap().positioned_visual_clusters(); + let first = positioned.next().unwrap(); + let mut cloned = positioned.clone(); + let second = positioned.next().unwrap(); + assert_ne!(first.0.path(), second.0.path(), "the iterator must advance"); + let cloned_second = cloned.next().unwrap(); + assert_eq!( + second.0.path(), + cloned_second.0.path(), + "clones share a starting position" + ); + assert_eq!( + second.1.to_bits(), + cloned_second.1.to_bits(), + "clones preserve the offset" + ); + assert_eq!( + positioned + .map(|(cluster, offset)| (cluster.path(), offset.to_bits())) + .collect::>(), + cloned + .map(|(cluster, offset)| (cluster.path(), offset.to_bits())) + .collect::>(), + "clones preserve run transitions" + ); +} + +#[test] +fn empty_text_and_box_only_lines_have_no_positioned_clusters() { + let mut fonts = create_font_context(); + let mut context = LayoutContext::<()>::new(); + for direction in [BaseDirection::Ltr, BaseDirection::Rtl] { + for box_count in [0, 3] { + let mut builder = context.ranged_builder(&mut fonts, "", 1.0, false); + builder.set_base_direction(direction); + for id in 0..box_count { + builder.push_inline_box(inline_box(id, 0, 5.5)); + } + let mut layout = builder.build(""); + layout.break_all_lines(Some(200.0)); + assert!(!layout.is_empty(), "there must be a line to visit"); + assert_eq!( + layout + .lines() + .flat_map(|line| line.positioned_visual_clusters()) + .count(), + 0 + ); + assert_positions_match(&layout); + } + } +} + +#[test] +fn long_single_run_positions_preserve_fractional_advances() { + let text = "A".repeat(8192); + let mut fonts = create_font_context(); + let mut context = LayoutContext::<()>::new(); + let mut builder = context.ranged_builder(&mut fonts, &text, 1.0, false); + builder.push_default(FontFamily::from(FONT_FAMILY_LIST)); + builder.push_default(crate::StyleProperty::LetterSpacing(0.37)); + let mut layout = builder.build(&text); + layout.break_all_lines(None); + assert_eq!(layout.len(), 1, "the long text must remain on one line"); + let line = layout.get(0).unwrap(); + assert_eq!(line.runs().count(), 1, "exercise a long individual run"); + let mut count = 0; + for (index, (cluster, offset)) in line.positioned_visual_clusters().enumerate() { + assert_eq!( + cluster.text_range(), + index..index + 1, + "visit every cluster once" + ); + // Sample the quadratic reference without making the regression test quadratic itself. + if [0, 1, 127, 1024, 8191].contains(&index) { + assert_eq!( + offset.to_bits(), + cluster.visual_offset().unwrap().to_bits(), + "long-run rounding at {index}" + ); + } + count += 1; + } + assert_eq!(count, text.len(), "all long-run clusters must be returned"); +} diff --git a/parley_bench/benches/main.rs b/parley_bench/benches/main.rs index 6e5537e7a..b4bc3cea7 100644 --- a/parley_bench/benches/main.rs +++ b/parley_bench/benches/main.rs @@ -6,8 +6,8 @@ use tango_bench::tango_benchmarks; use parley_bench::benches::{ - content_widths, defaults, inline_boxes, inline_host, iterate_glyph_runs, line_breaking, - long_line, page, repeated_justification, spacing, styled, + cluster_positions, content_widths, defaults, inline_boxes, inline_host, iterate_glyph_runs, + line_breaking, long_line, page, repeated_justification, spacing, styled, }; use parley_bench::fontique_benches::system_fonts_init; use parley_bench::query_benches::cursor; @@ -18,6 +18,7 @@ tango_benchmarks!( inline_host(), styled(), iterate_glyph_runs(), + cluster_positions(), content_widths(), spacing(), repeated_justification(), diff --git a/parley_bench/src/benches.rs b/parley_bench/src/benches.rs index 16f512e9f..f7a90864b 100644 --- a/parley_bench/src/benches.rs +++ b/parley_bench/src/benches.rs @@ -14,6 +14,54 @@ use std::hint::black_box; use std::ops::Range; use tango_bench::{Benchmark, benchmark_fn}; +/// Benchmark obtaining every cluster's position on increasingly long, unwrapped lines. +pub fn cluster_positions() -> Vec { + let mut benchmarks = Vec::new(); + for (label, sample) in [("LTR", "A"), ("Mixed RTL", "hello العربية ")] { + for count in [128_usize, 512, 2048] { + let text = sample + .repeat(count.div_ceil(sample.chars().count())) + .chars() + .take(count) + .collect::(); + for positioned in [false, true] { + let text = text.clone(); + let method = if positioned { + "iterator" + } else { + "visual_offset" + }; + benchmarks.push(benchmark_fn( + format!("Cluster Positions - {label}, {count} clusters, {method}"), + move |b| { + let mut layout = build_layout(&text, []); + layout.break_all_lines(None); + b.iter(move || { + for line in layout.lines() { + if positioned { + for (cluster, offset) in line.positioned_visual_clusters() { + black_box((cluster.path(), offset)); + } + } else { + for run in line.runs() { + for cluster in run.visual_clusters() { + black_box(( + cluster.path(), + cluster.visual_offset().unwrap(), + )); + } + } + } + } + }) + }, + )); + } + } + } + benchmarks +} + /// Benchmark building LTR and mixed-direction text with zero, one or many inline objects. /// /// The many-object cases also measure line breaking and alignment without rebuilding.