Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ Subheadings to categorize changes are `added, changed, deprecated, removed, fixe

This release has an [MSRV] of 1.88.

### Added

#### Parlance

- `BidiLevel` to encode bidirectional text embedding levels. ([#710][] by [@tomcur][])

### Changed

#### Parley
Expand Down Expand Up @@ -704,6 +710,7 @@ This release has an [MSRV][] of 1.70.
[#661]: https://github.com/linebender/parley/pull/661
[#671]: https://github.com/linebender/parley/pull/671
[#697]: https://github.com/linebender/parley/pull/697
[#710]: https://github.com/linebender/parley/pull/710

[Unreleased]: https://github.com/linebender/parley/compare/v0.11.0...HEAD
[0.11.0]: https://github.com/linebender/parley/compare/v0.10.0...v0.11.0
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions parlance/src/bidi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,76 @@ pub enum BidiOverride {
/// Force right-to-left.
Rtl,
}

/// Bidirectional text embedding level.
///
/// These are numbers indicating how deeply bidirectional embeddings are nested in the text, and the
/// default direction of text on that level. Even levels are left-to-right, odd levels are
/// right-to-left. Normally, the minimum level is 0 (left-to-right), and the maximum level,
/// according to [UAX #9 § 3.1.1 BD2][uax-bd2], is 125.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Incidentally, this being 125 does give us a bit to muck about with. I don't know of any use for that (maybe for whether rule L1 would apply to this?).

///
/// See [UAX #9 § 3.1][uax-definitions] for more information.
///
/// [uax-definitions]: https://unicode.org/reports/tr9/#Definitions
/// [uax-bd2]: https://unicode.org/reports/tr9/#BD2
///
// NOTICE: If the representation changes, be sure to check the `bytemuck` marker trait
// implementations.
//
// TODO: it would be quite nice for this to implement
// <https://doc.rust-lang.org/stable/core/iter/trait.Step.html>, once stabilized.
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
#[repr(transparent)]
pub struct BidiLevel(u8);

impl BidiLevel {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does it make sense for max level to be stored here, either a u8 or BidiLevel constant?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That sounds sensible. I've added BidiLevel::MAX, but perhaps you have feedback on the naming.

In particular, it's not the greatest value BidiLevel itself can represent.

/// The maximum bidirectional text embedding level, according to [UAX #9 § 3.1.1 BD2][uax-bd2].
///
/// It is possible for `BidiLevel` to encode greater values; in particular, `unsafe` code **must
/// not** rely on `BidiLevel` never being greater than this.
///
/// [uax-bd2]: https://unicode.org/reports/tr9/#BD2
pub const MAX: Self = Self(125);

/// Construct a new bidi level.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd claim that we should debug assert that this is less than or equal to 125, but not blockingly so.

@tomcur tomcur Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Perhaps that makes sense. This ties back into #710 (comment).

Deferring this for now.

#[inline(always)]
pub const fn new(level: u8) -> Self {
Self(level)
}

/// Get the numeric bidi level.
#[inline(always)]
pub const fn to_u8(self) -> u8 {
self.0
}

/// Whether this level is left-to-right.
#[inline(always)]
pub const fn is_ltr(self) -> bool {
self.0.is_multiple_of(2)
}

/// Whether this level is right-to-left.
#[inline(always)]
pub const fn is_rtl(self) -> bool {
!self.is_ltr()
}

/// Get the next odd bidi level.
///
/// When the return value overflows (`self.to_u8() >= 255`) this panics when overflow checks are
/// enabled. Otherwise, the return value wraps.
#[inline(always)]
pub const fn next_odd(self) -> Self {
Self::new((self.to_u8() + 1) | 1)
}

/// Get the next even bidi level.
///
/// When the return value overflows (`self.to_u8() >= 254`) this panics when overflow checks are
/// enabled. Otherwise, the return value wraps.
#[inline(always)]
pub const fn next_even(self) -> Self {
Self::new((self.to_u8() + 2) & !1)
}
}
25 changes: 22 additions & 3 deletions parlance/src/impl_bytemuck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
reason = "The `bytemuck` marker traits are `unsafe` and require `unsafe impl`."
)]

use crate::GenericFamily;
use bytemuck::{Contiguous, NoUninit, Zeroable, checked::CheckedBitPattern};
use crate::{BidiLevel, GenericFamily};
use bytemuck::{Contiguous, NoUninit, Pod, Zeroable, checked::CheckedBitPattern};

// Safety: The enum is `repr(u8)` and has only fieldless variants.
unsafe impl NoUninit for GenericFamily {}
Expand Down Expand Up @@ -39,12 +39,21 @@ unsafe impl Contiguous for GenericFamily {
const MAX_VALUE: u8 = GenericFamily::MAX_VALUE;
}

// Safety: The struct is `repr(transparent)`, wrapping a `u8`.
//
// While generally BidiLevels have a maximum of 125, no value is unsound.
unsafe impl Pod for BidiLevel {}

// Safety: The struct is `repr(transparent)`, wrapping a `u8`.
unsafe impl Zeroable for BidiLevel {}
Comment on lines +42 to +48

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We probably should have the usual safety tests for these. But also this is so simple it seems hard to imagine it going wrong! I'm not even sure what the tests would look like? Maybe even just that the size is 1 to force this to be revisited?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've added the size test. I'm also not sure whether we can do better.


#[cfg(test)]
mod tests {
use super::GenericFamily;
use bytemuck::{Contiguous, Zeroable, checked::try_from_bytes};
use core::ptr;

use super::{BidiLevel, GenericFamily};

#[test]
fn checked_bit_pattern() {
let valid = bytemuck::bytes_of(&2_u8);
Expand Down Expand Up @@ -86,6 +95,16 @@ mod tests {
value += 1;
}
};

/// Tests that [`BidiLevel`] is one byte.
///
/// That may catch its representation changing, in which case the implementations here
/// definitely need revisiting.
const _: () = {
if size_of::<BidiLevel>() != 1 {
panic!("`BidiLevel` is not one byte");
}
};
}

#[cfg(doctest)]
Expand Down
2 changes: 1 addition & 1 deletion parlance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ mod script;
mod tag;
mod text;

pub use bidi::{BidiControl, BidiDirection, BidiOverride};
pub use bidi::{BidiControl, BidiDirection, BidiLevel, BidiOverride};
pub use font::{FontStyle, FontWeight, FontWidth};
pub use font_family::{FontFamily, FontFamilyName, ParseFontFamilyError, ParseFontFamilyErrorKind};
pub use generic_family::GenericFamily;
Expand Down
2 changes: 2 additions & 0 deletions parley/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ accesskit = { workspace = true, optional = true }
hashbrown = { workspace = true }

[dev-dependencies]
parlance = { workspace = true, features = ["bytemuck"] }
parley_dev = { workspace = true }
peniko = { workspace = true }

bytemuck = { workspace = true }
icu_properties = { workspace = true, features = ["compiled_data"] }

# We special-case android targets because oxipng doesn't build in Android CI.
Expand Down
6 changes: 2 additions & 4 deletions parley/src/layout/alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ fn align_impl<B: Brush, const UNDO_JUSTIFICATION: bool>(
alignment: Alignment,
options: AlignmentOptions,
) {
// Whether the text base direction is right-to-left.
let is_rtl = layout.base_level & 1 == 1;
let is_rtl = layout.base_level.is_rtl();

// Apply alignment to line items
for line in &mut layout.lines {
Expand Down Expand Up @@ -170,9 +169,8 @@ fn align_impl<B: Brush, const UNDO_JUSTIFICATION: bool>(
.for_each(|line_item| {
let clusters =
&mut layout.shaped_text.clusters_mut()[line_item.cluster_range.clone()];
let line_item_is_rtl = line_item.bidi_level & 1 != 0;
let clusters: &mut dyn Iterator<Item = &mut ClusterData> =
if line_item_is_rtl {
if line_item.bidi_level.is_rtl() {
&mut clusters.iter_mut().rev()
} else {
&mut clusters.iter_mut()
Expand Down
17 changes: 9 additions & 8 deletions parley/src/layout/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{IndentOptions, InlineBoxKind, LineHeight, OverflowWrap, TextWrapMode
use core::ops::Range;

use alloc::vec::Vec;
use parlance::BidiLevel;
use parley_engine::shape::ClusterData;
use parley_engine::{Boundary, ShapedText};

Expand Down Expand Up @@ -68,7 +69,7 @@ pub(crate) struct LineItemData {
/// The index of the run or inline box in the runs or `inline_boxes` vec
pub(crate) index: usize,
/// Bidi level for the item (used for reordering)
pub(crate) bidi_level: u8,
pub(crate) bidi_level: BidiLevel,
/// Advance (size in direction of text flow) for the run.
pub(crate) advance: f32,

Expand All @@ -91,7 +92,7 @@ impl LineItemData {

#[inline(always)]
pub(crate) fn is_rtl(&self) -> bool {
self.bidi_level & 1 != 0
self.bidi_level.is_rtl()
}

/// If the item is a text run
Expand Down Expand Up @@ -143,7 +144,7 @@ pub(crate) struct LayoutItem {
/// The index of the run or inline box in the runs or `inline_boxes` vec
pub(crate) index: usize,
/// Bidi level for the item (used for reordering)
pub(crate) bidi_level: u8,
pub(crate) bidi_level: BidiLevel,
}

#[derive(Clone, Debug, PartialEq)]
Expand All @@ -154,7 +155,7 @@ pub(crate) struct LayoutData<B: Brush> {
/// Whether metrics should be quantized to pixel boundaries
pub(crate) quantize: bool,
/// The `BiDi` base level
pub(crate) base_level: u8,
pub(crate) base_level: BidiLevel,
/// The length of the text in the layout
pub(crate) text_len: usize,

Expand Down Expand Up @@ -199,7 +200,7 @@ impl<B: Brush> Default for LayoutData<B> {
Self {
scale: 1.,
quantize: true,
base_level: 0,
base_level: BidiLevel::new(0),
text_len: 0,
width: 0.,
full_width: 0.,
Expand All @@ -225,7 +226,7 @@ impl<B: Brush> LayoutData<B> {
pub(crate) fn clear(&mut self) {
self.scale = 1.;
self.quantize = true;
self.base_level = 0;
self.base_level = BidiLevel::new(0);
self.text_len = 0;
self.width = 0.;
self.full_width = 0.;
Expand All @@ -248,7 +249,7 @@ impl<B: Brush> LayoutData<B> {
.runs()
.last()
.map(|r| r.bidi_level)
.unwrap_or(0);
.unwrap_or(BidiLevel::new(0));

self.items.push(LayoutItem {
kind: LayoutItemKind::InlineBox,
Expand Down Expand Up @@ -353,7 +354,7 @@ impl<B: Brush> LayoutData<B> {
let mut running_max_width = 0.0;
let mut text_wrap_mode = TextWrapMode::Wrap;
let mut prev_cluster: Option<&ClusterData> = None;
let is_rtl = self.base_level & 1 == 1;
let is_rtl = self.base_level.is_rtl();
for item in &self.items {
match item.kind {
LayoutItemKind::TextRun => {
Expand Down
2 changes: 1 addition & 1 deletion parley/src/layout/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ impl<B: Brush> Layout<B> {

/// Returns `true` if the dominant direction of the layout is right-to-left.
pub fn is_rtl(&self) -> bool {
self.data.base_level & 1 != 0
self.data.base_level.is_rtl()
}

pub fn inline_boxes(&self) -> &[InlineBox] {
Expand Down
19 changes: 10 additions & 9 deletions parley/src/layout/line_break.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use alloc::vec::Vec;
#[cfg(feature = "libm")]
#[allow(unused_imports)]
use core_maths::CoreFloat;
use parlance::BidiLevel;

use crate::layout::{
BreakReason, Layout, LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData,
Expand Down Expand Up @@ -1164,7 +1165,7 @@ impl<'a, B: Brush> BreakLines<'a, B> {

// Mark line as needing bidi re-ordering if it contains any runs with non-zero bidi level
// (zero is the default level, so this is equivalent to marking lines that have multiple levels)
if line_item.bidi_level != 0 {
if line_item.bidi_level != BidiLevel::new(0) {
needs_reorder = true;
}

Expand Down Expand Up @@ -1260,7 +1261,7 @@ impl<'a, B: Brush> BreakLines<'a, B> {
self.lines.line_items.push(LineItemData {
kind: LayoutItemKind::TextRun,
index,
bidi_level: 0,
bidi_level: BidiLevel::new(0),
advance: 0.,
is_whitespace: false,
has_trailing_whitespace: false,
Expand Down Expand Up @@ -1512,16 +1513,16 @@ fn reorder_line_items(runs: &mut [LineItemData]) {
let mut lowest_odd_level = 255;
for run in runs.iter() {
let level = run.bidi_level;
let is_odd = level & 1 != 0;
let is_odd = level.to_u8() & 1 != 0;

// Update max level
if level > max_level {
max_level = level;
if level.to_u8() > max_level {
max_level = level.to_u8();
}

// Update min odd level
if is_odd && level < lowest_odd_level {
lowest_odd_level = level;
if is_odd && level.to_u8() < lowest_odd_level {
lowest_odd_level = level.to_u8();
}
}

Expand All @@ -1530,9 +1531,9 @@ fn reorder_line_items(runs: &mut [LineItemData]) {
// Iterate over text runs
let mut i = 0;
while i < run_count {
if runs[i].bidi_level >= level {
if runs[i].bidi_level.to_u8() >= level {
let mut end = i + 1;
while end < run_count && runs[end].bidi_level >= level {
while end < run_count && runs[end].bidi_level.to_u8() >= level {
end += 1;
}

Expand Down
2 changes: 1 addition & 1 deletion parley/src/layout/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ impl<'a, B: Brush> Run<'a, B> {

/// Returns `true` if the run has right-to-left directionality.
pub fn is_rtl(&self) -> bool {
self.shaped.bidi_level & 1 != 0
self.shaped.bidi_level.is_rtl()
}
Comment on lines 111 to 114

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess nominally it might be better to just return the bidi level here, but that's not done here to avoid breaking changes?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, I intentionally did not change the parley API here.

That said, we probably should break it. (But not in this PR.)


/// Returns the cluster range for the run.
Expand Down
Loading
Loading