Skip to content
Closed
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
103 changes: 99 additions & 4 deletions parley/src/layout/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

use crate::inline_box::InlineBox;
use crate::layout::spacing::{EffectiveSpacing, Justification, Spacing};
use crate::layout::whitespace::{atom_hanging_advance, whitespace_hangs};
use crate::layout::whitespace::{
atom_hanging_advance, is_break_space, is_break_space_class, soft_line_break, whitespace_hangs,
};
use crate::layout::{ContentWidths, LineMetrics, Style};
use crate::resolve::ResolvedStyle;
use crate::style::Brush;
Expand Down Expand Up @@ -300,6 +302,13 @@ impl<B: Brush> LayoutData<B> {

let mut text_wrap_mode = TextWrapMode::Wrap;

// `break-spaces` adds soft break opportunities after preserved spaces, which the flat
// per-cluster scan below only has to check for when a style actually uses it.
let any_break_spaces = self
.styles
.iter()
.any(|style| style.white_space_collapse == WhiteSpaceCollapse::BreakSpaces);

for item in &self.items {
match item.kind {
LayoutItemKind::TextRun => {
Expand All @@ -313,17 +322,103 @@ impl<B: Brush> LayoutData<B> {
let can_hang = item.bidi_level == self.base_level;
let is_rtl = item.bidi_level.is_rtl();

if run_spacing.is_zero() {
// Without letter/word spacing there are no per-atom gaps, so the run can be
// scanned one shaped cluster at a time instead of materializing atoms:
// atoms start at grapheme-start clusters, so break opportunities are only
// considered there, and the hanging-whitespace accumulator is updated per
// cluster (a cluster that doesn't hang resets it, one that does extends
// it), which is equivalent to the per-atom "hangs entirely / hangs
// partially from the logical end" rule of `atom_hanging_advance`.
let clusters = slice.shaped_clusters();
let all_characters = self.shaped_text.characters();
let mut skip_atom = false;
for (i, cluster) in clusters.iter().enumerate() {
let whitespace = cluster.whitespace();
let style = &self.styles[cluster.style_index as usize];
if i == 0 || cluster.is_grapheme_start() {
skip_atom = false;
let prev_text_wrap_mode = text_wrap_mode;
text_wrap_mode = style.text_wrap_mode;
// Mirrors `soft_line_break`, using the cached cluster flags for the
// cluster's first character.
let soft_break = if any_break_spaces {
let start = cluster.chars_range().start as usize;
(cluster.boundary_before() == Boundary::Line
&& !is_break_space_class(whitespace, style))
|| (start > 0
&& is_break_space(
all_characters[start - 1],
&self.styles,
))
} else {
cluster.boundary_before() == Boundary::Line
};
if prev_text_wrap_mode == TextWrapMode::Wrap
&& (soft_break || style.overflow_wrap == OverflowWrap::Anywhere)
{
min_width = min_width
.max(running_min_width - running_hanging_whitespace);
running_min_width = 0.0;
}
// See the newline handling below.
if whitespace == Whitespace::Newline {
min_width = min_width
.max(running_min_width - running_hanging_whitespace);
if !hangs_conditionally {
running_max_width -= running_hanging_whitespace;
}
max_width = max_width.max(running_max_width);
running_min_width = 0.0;
running_max_width = 0.0;
running_hanging_whitespace = 0.0;
skip_atom = true;
continue;
}
} else if skip_atom {
continue;
}
let advance = cluster.advance;
running_min_width += advance;
running_max_width += advance;
// A cluster hangs if all of its characters hang. Its first character is
// checked via the cached flags so the common case never touches
// `characters`.
let hangs = can_hang
&& whitespace_hangs(whitespace, style)
&& (cluster.char_len() == 1
|| slice.characters_in(cluster.chars_range()).iter().all(
|character| {
whitespace_hangs(
character.info.whitespace(),
&self.styles[character.style_index as usize],
)
},
));
if hangs {
running_hanging_whitespace += advance;
hangs_conditionally =
style.white_space_collapse == WhiteSpaceCollapse::Preserve;
} else {
running_hanging_whitespace = 0.0;
}
}
continue;
}

for atom in slice.atoms_start() {
let characters = atom.characters();
let first_character = characters[0];
let whitespace = first_character.info.whitespace();
let boundary = first_character.info.boundary();
let style = &self.styles[first_character.style_index as usize];
let prev_text_wrap_mode = text_wrap_mode;
text_wrap_mode = style.text_wrap_mode;
if prev_text_wrap_mode == TextWrapMode::Wrap
&& (boundary == Boundary::Line
|| style.overflow_wrap == OverflowWrap::Anywhere)
&& (soft_line_break(
self.shaped_text.characters(),
atom.char_range().start as usize,
&self.styles,
) || style.overflow_wrap == OverflowWrap::Anywhere)
{
min_width =
min_width.max(running_min_width - running_hanging_whitespace);
Expand Down
12 changes: 8 additions & 4 deletions parley/src/layout/line_break.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use core_maths::CoreFloat;
use parlance::BidiLevel;

use crate::layout::spacing::{EffectiveSpacing, Justification, is_word_separator};
use crate::layout::whitespace::atom_hanging_advance;
use crate::layout::whitespace::{atom_hanging_advance, soft_line_break};
use crate::layout::{
BreakReason, Layout, LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData,
LineMetrics, Run,
Expand All @@ -21,7 +21,7 @@ use crate::{InlineBoxKind, OverflowWrap, TextWrapMode, WhiteSpaceCollapse};

use core::ops::Range;
use parley_engine::shape::Whitespace;
use parley_engine::{Atom, Boundary, FontMetrics};
use parley_engine::{Atom, FontMetrics};

#[derive(Default)]
struct LineLayout {
Expand Down Expand Up @@ -787,7 +787,6 @@ impl<'a, B: Brush> BreakLines<'a, B> {
let is_newline = whitespace == Whitespace::Newline;
// Whether this atom is a justification opportunity.
let is_separator = is_word_separator(whitespace);
let boundary = first_character.info.boundary();
let max_height_exceeded = self.state.line.max_height_exceeded;
let style = &self.layout.data.styles[first_character.style_index as usize];

Expand Down Expand Up @@ -849,7 +848,12 @@ impl<'a, B: Brush> BreakLines<'a, B> {
max_advance,
line_indent,
);
} else if boundary == Boundary::Line && text_wrap_mode == TextWrapMode::Wrap
} else if text_wrap_mode == TextWrapMode::Wrap
&& soft_line_break(
self.layout.data.shaped_text.characters(),
atom.char_range().start as usize,
&self.layout.data.styles,
)
{
// We don't record boundaries when the advance is 0. As we do not want overflowing content to cause extra consecutive
// line breaks. We should accept the overflowing fragment in that scenario.
Expand Down
50 changes: 45 additions & 5 deletions parley/src/layout/whitespace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

//! Some whitespace-related utilities.

use parley_engine::shape::Whitespace;
use parley_engine::{Atom, ShapedSlice};
use parley_engine::shape::{Character, Whitespace};
use parley_engine::{Atom, Boundary, ShapedSlice};

use crate::layout::Style;
use crate::layout::spacing::EffectiveSpacing;
Expand All @@ -16,7 +16,7 @@ impl WhiteSpaceCollapse {
pub(crate) fn is_collapsible(self, c: char) -> bool {
match self {
Self::Collapse => c.is_ascii_whitespace(),
Self::Preserve => false,
Self::Preserve | Self::BreakSpaces => false,
Self::PreserveBreaks => matches!(c, ' ' | '\t'),
}
}
Expand All @@ -39,13 +39,53 @@ pub(crate) fn whitespace_hangs<B: Brush>(whitespace: Whitespace, style: &Style<B
match whitespace {
Whitespace::Newline => true,
Whitespace::Space | Whitespace::IdeographicSpace | Whitespace::Tab => {
style.white_space_collapse != WhiteSpaceCollapse::Preserve
|| style.text_wrap_mode == TextWrapMode::Wrap
match style.white_space_collapse {
WhiteSpaceCollapse::BreakSpaces => false,
WhiteSpaceCollapse::Preserve => style.text_wrap_mode == TextWrapMode::Wrap,
_ => true,
}
}
_ => false,
}
}

/// Whether whitespace of the given class and style is a preserved `break-spaces` space, i.e.
/// one that creates a soft break opportunity after it.
#[inline(always)]
pub(crate) fn is_break_space_class<B: Brush>(whitespace: Whitespace, style: &Style<B>) -> bool {
matches!(
whitespace,
Whitespace::Space | Whitespace::Tab | Whitespace::IdeographicSpace
) && style.white_space_collapse == WhiteSpaceCollapse::BreakSpaces
}

#[inline(always)]
pub(crate) fn is_break_space<B: Brush>(character: Character, styles: &[Style<B>]) -> bool {
is_break_space_class(
character.info.whitespace(),
&styles[character.style_index as usize],
)
}

/// Whether a soft line break opportunity exists before the character at `index`.
///
/// Following [CSS Text 4 § 5.1][css-break-spaces], `white-space-collapse: break-spaces` permits
/// wrapping after each preserved space or tab, but not before the first one. Other Unicode
/// separators retain their UAX #14 opportunities.
///
/// [css-break-spaces]: https://www.w3.org/TR/css-text-4/#white-space-collapsing
pub(crate) fn soft_line_break<B: Brush>(
characters: &[Character],
index: usize,
styles: &[Style<B>],
) -> bool {
if index > 0 && is_break_space(characters[index - 1], styles) {
return true;
}
let character = characters[index];
character.info.boundary() == Boundary::Line && !is_break_space(character, styles)
}

/// The advance of the logically trailing clusters of `atom` that hang past the line's end, and
/// whether that is the atom in its entirety.
///
Expand Down
2 changes: 1 addition & 1 deletion parley/src/resolve/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ impl<B: Brush> TreeStyleBuilder<B> {

let span = self.current_span;
match self.tree[span].style.white_space_collapse {
WhiteSpaceCollapse::Preserve => {
WhiteSpaceCollapse::Preserve | WhiteSpaceCollapse::BreakSpaces => {
if uncommitted_text.starts_with(is_segment_break) {
// Pending whitespace is always from a `WhiteSpaceCollapse::Collapse` or
// `WhiteSpaceCollapse::PreserveBreaks` span, and following CSS Text 4 § 4.3.1 Rule 1
Expand Down
2 changes: 2 additions & 0 deletions parley/src/style/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub enum WhiteSpaceCollapse {
Preserve,
/// Collapse spaces and tabs while preserving segment breaks.
PreserveBreaks,
/// Preserve whitespace without hanging, allowing wrapping after each space or tab.
BreakSpaces,
}

/// The height that this text takes up. The default is `MetricsRelative(1.0)`, which is the given
Expand Down
101 changes: 101 additions & 0 deletions parley_engine/src/shape/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ impl ShapedClusterFlags {
const INLINE_GLYPH: u16 = 1 << 8;
const GRAPHEME_START: u16 = 1 << 9;
const SAFE_TO_BREAK_BEFORE: u16 = 1 << 10;
/// [`Boundary`] before the cluster's first character (2 bits).
const BOUNDARY_SHIFT: u16 = 11;
const BOUNDARY_MASK: u16 = 0b11 << Self::BOUNDARY_SHIFT;
/// [`Whitespace`] class of the cluster's first character (3 bits).
const WHITESPACE_SHIFT: u16 = 13;
const WHITESPACE_MASK: u16 = 0b111 << Self::WHITESPACE_SHIFT;

#[inline(always)]
pub(crate) const fn new(glyph_len: u8) -> Self {
Expand All @@ -62,6 +68,42 @@ impl ShapedClusterFlags {
self
}

#[inline(always)]
pub(crate) const fn with_first_char(
mut self,
boundary: Boundary,
whitespace: Whitespace,
) -> Self {
self.0 = self.0 & !(Self::BOUNDARY_MASK | Self::WHITESPACE_MASK)
| ((boundary as u16) << Self::BOUNDARY_SHIFT)
| ((whitespace as u16) << Self::WHITESPACE_SHIFT);
self
}

#[inline(always)]
const fn boundary(self) -> Boundary {
match (self.0 & Self::BOUNDARY_MASK) >> Self::BOUNDARY_SHIFT {
0 => Boundary::None,
1 => Boundary::Word,
2 => Boundary::Line,
_ => Boundary::Mandatory,
}
}

#[inline(always)]
const fn whitespace(self) -> Whitespace {
match (self.0 & Self::WHITESPACE_MASK) >> Self::WHITESPACE_SHIFT {
1 => Whitespace::Space,
2 => Whitespace::NoBreakSpace,
3 => Whitespace::IdeographicSpace,
4 => Whitespace::OtherSpaceSeparator,
5 => Whitespace::Tab,
6 => Whitespace::Newline,
7 => Whitespace::ControlWhitespace,
_ => Whitespace::None,
}
}

#[inline(always)]
const fn glyph_len(self) -> u8 {
(self.0 & Self::GLYPH_LEN_MASK) as u8
Expand Down Expand Up @@ -167,6 +209,29 @@ impl ShapedCluster {
self.flags.is_safe_to_break_before()
}

/// The [`Boundary`] before this cluster's first character.
///
/// This is [`Character::info`]'s boundary of the first character of [`Self::chars_range`], cached
/// here so that measuring text does not need to touch the character array.
#[inline(always)]
pub fn boundary_before(self) -> Boundary {
self.flags.boundary()
}

/// The [`Whitespace`] class of this cluster's first character.
///
/// See [`Self::boundary_before`].
#[inline(always)]
pub fn whitespace(self) -> Whitespace {
self.flags.whitespace()
}

/// The number of characters in this cluster.
#[inline(always)]
pub fn char_len(self) -> u32 {
self.chars_range.1 - self.chars_range.0
}

/// The number of graphemes this cluster overlaps.
pub(crate) fn graphemes_overlapped(&self, characters: &[Character]) -> u32 {
let start = self.chars_range().start as usize + 1;
Expand Down Expand Up @@ -233,3 +298,39 @@ impl ClusterInfo {
self.source_char
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn first_char_flags_round_trip() {
let boundaries = [
Boundary::None,
Boundary::Word,
Boundary::Line,
Boundary::Mandatory,
];
let whitespaces = [
Whitespace::None,
Whitespace::Space,
Whitespace::NoBreakSpace,
Whitespace::IdeographicSpace,
Whitespace::OtherSpaceSeparator,
Whitespace::Tab,
Whitespace::Newline,
Whitespace::ControlWhitespace,
];
for boundary in boundaries {
for whitespace in whitespaces {
let flags = ShapedClusterFlags::new(3)
.with_grapheme_start(true)
.with_first_char(boundary, whitespace);
assert_eq!(flags.boundary(), boundary);
assert_eq!(flags.whitespace(), whitespace);
assert_eq!(flags.glyph_len(), 3);
assert!(flags.is_grapheme_start());
}
}
}
}
Loading
Loading