Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 50 additions & 14 deletions parley/src/layout/alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,45 @@ fn align_impl<B: Brush, const UNDO_JUSTIFICATION: bool>(
for line in &mut layout.lines {
let indent = line.indent;

// Hanging whitespace is an alignment/positioning concern, orthogonal to text
// directionality. The RTL handling here doesn't interact with bidi reordering:
// it simply shifts the line's origin so that hung trailing whitespace (which
// sits at the *start* edge in visual order for RTL) overflows into the start
// margin rather than displacing visible content.
Comment on lines +107 to +111
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This smells like a Claude artifact to me. I'd lean towards reverting this section.

if is_rtl {
// In RTL text, trailing whitespace is on the left. As we hang that whitespace, offset
// the line to the left. Note: indent is not subtracted here because `free_space` below
// already accounts for it.
line.metrics.offset = -line.metrics.trailing_whitespace;
} else {
line.metrics.offset = indent;
}

// Compute free space.
let line_width = line.metrics.inline_max_coord - line.metrics.inline_min_coord;
let free_space =
line_width - indent - line.metrics.advance + line.metrics.trailing_whitespace;
// `line.metrics.inline_max_coord - line.metrics.inline_min_coord` = width of line
let pre_hang_free_space = line.metrics.inline_max_coord - line.metrics.inline_min_coord - indent - line.metrics.advance;

// CSS Text 3 conditional vs. unconditional hanging of trailing whitespace.
//
// Per https://www.w3.org/TR/css-text-3/#white-space-phase-2, a sequence of
// preserved trailing spaces at the end of a line is:
// - "unconditionally hung" at soft wrap opportunities — the whitespace is
// excluded from the line's measured width, so alignment/justification
// ignores it (it visually overflows into the end margin).
// - "conditionally hung" at forced line breaks and at the end of the
// block (i.e. the paragraph-final line) — the whitespace is measured
// as normal, and only hangs if it would otherwise overflow the line
// box. In practice this means: include it in alignment calculations,
// and clamp free_space to 0 on overflow so visible content stops at
// the start edge rather than being pushed past it.
//
// `BreakReason::Explicit` covers forced breaks (e.g. `\n`);
// `BreakReason::None` covers the final line of the block.
// Both get the conditional-hanging treatment.
let free_space = if matches!(line.break_reason, BreakReason::Explicit | BreakReason::None) {
// Include trailing whitespace; clamp to 0 when it overflows.
pre_hang_free_space.max(0.0)
} else {
// Non-paragraph-final: unconditional hanging (trailing whitespace excluded)
pre_hang_free_space + line.metrics.trailing_whitespace
};

if !options.align_when_overflowing && free_space <= 0.0 {
if is_rtl {
Expand All @@ -143,20 +169,30 @@ fn align_impl<B: Brush, const UNDO_JUSTIFICATION: bool>(
}

// Justified alignment doesn't apply to the last line of a paragraph
// (`BreakReason::None`), (`BreakReason::Explicit`) or if there are no whitespace
// gaps to adjust. In that case, start-align, i.e., left-align for LTR text and
// right-align for RTL text.
if matches!(line.break_reason, BreakReason::None | BreakReason::Explicit)
|| line.num_spaces == 0
{
// (`BreakReason::None`), (`BreakReason::Explicit`). In that case, start-align,
// i.e., left-align for LTR text and right-align for RTL text.
if matches!(line.break_reason, BreakReason::None | BreakReason::Explicit) {
if is_rtl {
line.metrics.offset += free_space;
}
continue;
}

// Spaces that participate in justification: total spaces on the line minus
// trailing spaces (which are hung and should not receive extra width).
// Both counts are pre-computed during line breaking.
let justifiable_spaces = line.total_spaces
.saturating_sub(line.trailing_space_count);

if justifiable_spaces == 0 {
if is_rtl {
line.metrics.offset += free_space;
}
continue;
}

let adjustment =
free_space / line.num_spaces as f32 * if UNDO_JUSTIFICATION { -1. } else { 1. };
free_space / justifiable_spaces as f32 * if UNDO_JUSTIFICATION { -1. } else { 1. };
let mut applied = 0;
// Iterate over text runs in the line and clusters in the text run
// - Iterate forwards for even bidi levels (which represent LTR runs)
Expand All @@ -178,7 +214,7 @@ fn align_impl<B: Brush, const UNDO_JUSTIFICATION: bool>(
&mut clusters.iter_mut()
};
clusters.for_each(|cluster| {
if applied == line.num_spaces {
if applied == justifiable_spaces {
return;
}
if cluster.info.whitespace().is_space_or_nbsp() {
Expand Down
10 changes: 8 additions & 2 deletions parley/src/layout/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,14 @@ pub(crate) struct LineData {
pub(crate) break_reason: BreakReason,
/// Maximum advance for the line.
pub(crate) max_advance: f32,
/// Number of justified clusters on the line.
pub(crate) num_spaces: usize,
/// Total number of space/nbsp clusters on the line.
pub(crate) total_spaces: usize,
/// Number of trailing space/nbsp clusters that are hung rather than justified.
///
/// Per CSS Text 3 §4.1.3 (`white-space` processing, phase 2): trailing preserved spaces
/// are either unconditionally hung (soft-wrapped lines) or conditionally hung (forced
/// breaks / end of block). In both cases they are excluded from justification distribution.
pub(crate) trailing_space_count: usize,
/// Text indent applied to this line.
pub(crate) indent: f32,
}
Expand Down
127 changes: 89 additions & 38 deletions parley/src/layout/line_break.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ use core_maths::CoreFloat;

use crate::analysis::Boundary;
use crate::analysis::cluster::Whitespace;
use crate::data::ClusterData;
use crate::layout::{
BreakReason, Layout, LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData,
LineMetrics, Run,
Expand Down Expand Up @@ -651,10 +650,24 @@ impl<'a, B: Brush> BreakLines<'a, B> {
return self.max_height_break_data(line_height);
}
self.state.append_cluster_to_line(next_x, line_height);
if try_commit_line!(BreakReason::Regular) {
// TODO: can this be hoisted out of the conditional?
self.state.line.num_spaces += 1;
// CSS conditional hanging: trailing whitespace before a forced
// break (or end of text) should not cause line wrapping. Only
// wrap if there is visible content before the next forced break.
if self.has_visible_content_before_forced_break(
self.state.cluster_idx + 1,
self.state.item_idx,
cluster_end,
) {
// Mid-paragraph: hang overflowing whitespace and wrap
if try_commit_line!(BreakReason::Regular) {
// TODO: can this be hoisted out of the conditional?
self.state.cluster_idx += 1;
Comment on lines +664 to +665
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly surprised that this actually gets all the remaining space clusters in the line; my impression was previously that the reason we were seeing the wrapping behaviour was because this is +1. That is, do we not need to consume clusters until we reach the last cluster on the line? We might need to do more digging

return self.start_new_line(BreakReason::Regular);
}
} else {
// Paragraph-final: accumulate space without wrapping
self.state.cluster_idx += 1;
return self.start_new_line(BreakReason::Regular);
}
}
// Case: we have previously encountered a REGULAR line-breaking opportunity in the current line
Expand Down Expand Up @@ -1029,31 +1042,34 @@ impl<'a, B: Brush> BreakLines<'a, B> {
reorder_line_items(&mut self.lines.line_items[line.item_range.clone()]);
}

// Compute size of line's trailing whitespace. "Trailing" is considered the right edge
// for LTR text and the left edge for RTL text.
let run = if self.layout.is_rtl() {
// Compute trailing whitespace advance and count.
//
// "Trailing" means the line's end edge: last item for LTR base direction, first for RTL.
// Within that item, clusters are iterated from the logical end (`.rev()`), skipping any
// newline cluster (zero-advance forced break) to reach the hangable space/nbsp sequence.
//
// This measurement is direction-independent because UAX#9 assigns trailing whitespace
// the paragraph's base embedding level — trailing spaces always end up in a run matching
// the base direction, so iterating from the logical end of that run is correct regardless
// of whether the base is LTR or RTL.
let trailing_run = if self.layout.is_rtl() {
self.lines.line_items[line.item_range.clone()].first()
} else {
self.lines.line_items[line.item_range.clone()].last()
};
line.metrics.trailing_whitespace = run
let (trailing_ws_advance, trailing_ws_count) = trailing_run
.filter(|item| item.is_text_run() && item.has_trailing_whitespace)
.map(|run| {
fn whitespace_advance<'c, I: Iterator<Item = &'c ClusterData>>(clusters: I) -> f32 {
clusters
.take_while(|cluster| cluster.info.whitespace() != Whitespace::None)
.map(|cluster| cluster.advance)
.sum()
}

let clusters = &self.layout.data.clusters[run.cluster_range.clone()];
if run.is_rtl() {
whitespace_advance(clusters.iter())
} else {
whitespace_advance(clusters.iter().rev())
}
self.layout.data.clusters[run.cluster_range.clone()]
.iter()
.rev()
.skip_while(|cluster| cluster.info.whitespace() == Whitespace::Newline)
.take_while(|cluster| cluster.info.whitespace().is_space_or_nbsp())
.fold((0.0_f32, 0_usize), |(adv, n), cluster| (adv + cluster.advance, n + 1))
})
.unwrap_or(0.0);
.unwrap_or((0.0, 0));
line.metrics.trailing_whitespace = trailing_ws_advance;
line.trailing_space_count = trailing_ws_count;

if !have_metrics {
// Line consisting entirely of whitespace?
Expand Down Expand Up @@ -1148,6 +1164,55 @@ impl<'a, B: Brush> BreakLines<'a, B> {
line.metrics.inline_min_coord = self.state.line_x;
line.metrics.inline_max_coord = self.state.line_x + self.state.line_max_advance;
}

/// Returns `true` if there is visible (non-whitespace) content between `from_cluster` and
/// the next forced break (or end of text).
///
/// This distinguishes mid-paragraph trailing whitespace (which is unconditionally hung per
/// CSS Text 3 §4.1.3) from paragraph-final trailing whitespace (conditionally hung).
/// When this returns `false`, the line breaker suppresses the soft wrap so the trailing
/// spaces accumulate on the current line for conditional hanging during alignment.
fn has_visible_content_before_forced_break(
&self,
from_cluster: usize,
current_item_idx: usize,
current_run_cluster_end: usize,
) -> bool {
let items = &self.layout.data.items;
let clusters = &self.layout.data.clusters;

// Check remaining clusters in the current text run
for cluster in &clusters[from_cluster..current_run_cluster_end] {
let ws = cluster.info.whitespace();
if ws == Whitespace::Newline {
return false;
}
if !ws.is_space_or_nbsp() {
return true;
}
}

// Check subsequent items
for item in &items[(current_item_idx + 1)..] {
match item.kind {
LayoutItemKind::InlineBox => return true,
LayoutItemKind::TextRun => {
let run_data = &self.layout.data.runs[item.index];
for cluster in &clusters[run_data.cluster_range.clone()] {
let ws = cluster.info.whitespace();
if ws == Whitespace::Newline {
return false;
}
if !ws.is_space_or_nbsp() {
return true;
}
}
}
}
}

false // Reached end of text, no visible content
}
}

impl<B: Brush> Drop for BreakLines<'_, B> {
Expand Down Expand Up @@ -1365,26 +1430,12 @@ fn try_commit_line<B: Brush>(
// return false;
// }

// Exclude the trailing space from justification space count.
// Only subtract if the line actually ends with a space — with
// WordBreak::BreakAll, regular breaks can land between non-space
// characters, in which case there is no trailing space to exclude.
let mut num_spaces = state.num_spaces;
if break_reason == BreakReason::Regular
&& state.clusters.start < state.clusters.end
&& layout.data.clusters[state.clusters.end - 1]
.info
.whitespace()
.is_space_or_nbsp()
{
num_spaces = num_spaces.saturating_sub(1);
}

lines.lines.push(LineData {
item_range: start_item_idx..end_item_idx,
max_advance,
break_reason,
num_spaces,
total_spaces: state.num_spaces,
trailing_space_count: 0, // computed later in start_new_line
indent: line_indent,
metrics: LineMetrics {
advance: state.x,
Expand Down
Binary file modified parley_tests/snapshots/base_level_alignment_ltr-justify.png
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't clear to me why this snapshot has been regenerated. Do you have that clear?

I can't see any actual changes, so I'm not overly worried.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified parley_tests/snapshots/custom_break_lines_circle_layout-0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified parley_tests/snapshots/issue_409-paragraphs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified parley_tests/snapshots/realign-0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified parley_tests/snapshots/text_indent_justify-0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading