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
56 changes: 45 additions & 11 deletions parley/src/layout/alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,33 @@ fn align_impl<B: Brush, const UNDO_JUSTIFICATION: bool>(
}

// 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 +167,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 +212,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
73 changes: 32 additions & 41 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 @@ -645,17 +644,19 @@ impl<'a, B: Brush> BreakLines<'a, B> {
else {
// Case: cluster is a space character (and wrapping is enabled)
//
// We hang any overflowing whitespace and then line-break.
// Hang the overflowing whitespace by appending it to the current
// line and continuing without committing a line break.
//
// The space is allowed to overflow; the actual break will occur when
// the next non-space character triggers a break opportunity.
// Multiple, consecutive trailing spaces are hanged with this approach.
if is_space && text_wrap_mode == TextWrapMode::Wrap {
if max_height_exceeded {
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.cluster_idx += 1;
return self.start_new_line(BreakReason::Regular);
}
self.state.line.num_spaces += 1;
self.state.cluster_idx += 1;
}
// Case: we have previously encountered a REGULAR line-breaking opportunity in the current line
//
Expand Down Expand Up @@ -1029,31 +1030,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 +1152,7 @@ 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;
}

}

impl<B: Brush> Drop for BreakLines<'_, B> {
Expand Down Expand Up @@ -1365,26 +1370,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
10 changes: 8 additions & 2 deletions parley/src/shape/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ fn fill_cluster_in_place(
// regional indicators, subdivision flag tag sequences, skin tone modifiers
// See also: https://github.com/google/emoji-segmenter

// ======== DISABLED: START ========

// If the color emoji has a non-printing variation selector, ignore the variation selector.
// Its presentation depends on the platform and font.
//
Expand All @@ -259,11 +261,15 @@ fn fill_cluster_in_place(
// - `U+270C + U+FE0F`: `✌️`, force emoji presentation
//
// <https://www.unicode.org/reports/tr37/>
let is_emoji_with_non_printing_variation_selector =
/*let is_emoji_with_non_printing_variation_selector =
is_emoji_or_pictograph && info.is_variation_selector();

let contributes_to_shaping =
info.contributes_to_shaping() && !is_emoji_with_non_printing_variation_selector;
info.contributes_to_shaping(); && !is_emoji_with_non_printing_variation_selector;*/

// ======== DISABLED: END ========

let contributes_to_shaping = info.contributes_to_shaping();
if contributes_to_shaping {
map_len += 1;
}
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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Sub-pixel shifting in the horizontal position of "amet" on line two; all else looks the same.

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