fix(layout): place inline text in the first fitting float band

This commit is contained in:
ldm0
2026-10-05 00:50:50 +08:00
parent 14c7f3e88f
commit 25f22f36f2
50 changed files with 15035 additions and 3 deletions
+2
View File
@@ -8,6 +8,8 @@
/node_modules
/tmp
/vendor/*
!/vendor/parley-0.11.1/
!/vendor/parley-0.11.1/**
!/vendor/v8-152.2.0/
!/vendor/v8-152.2.0/**
!/vendor/deno_v8-0.3.0/
Generated
-2
View File
@@ -4159,8 +4159,6 @@ checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5"
[[package]]
name = "parley"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5"
dependencies = [
"fontique",
"harfrust",
+2 -1
View File
@@ -91,12 +91,13 @@ members = [
"moli-http-cache",
"moli-wpt-compat"
]
exclude = ["vendor/xml5ever-0.39.0", "vendor/web-audio-api-1.7.0", "vendor/usvg-0.48.1"]
exclude = ["vendor/parley-0.11.1", "vendor/xml5ever-0.39.0", "vendor/web-audio-api-1.7.0", "vendor/usvg-0.48.1"]
[workspace.package]
license = "MIT OR Apache-2.0"
[patch.crates-io]
parley = { path = "vendor/parley-0.11.1" }
usvg = { path = "vendor/usvg-0.48.1" }
web-audio-api = { path = "vendor/web-audio-api-1.7.0" }
xml5ever = { path = "vendor/xml5ever-0.39.0" }
+149
View File
@@ -299,6 +299,155 @@ fn inline_float_content_height_includes_both_padding_and_border_edges() {
assert_close(rect(&snapshot, GREEN).height, 26.0);
}
#[test]
fn unbreakable_inline_text_moves_below_a_float_when_its_slot_is_too_narrow() {
let source = Source(vec![
Node::element("root", "div", LayoutElementCategory::Generic, None, vec![1]),
Node::element(
"container",
"div",
LayoutElementCategory::Generic,
None,
vec![2, 3],
),
Node::element(
"float",
"img",
LayoutElementCategory::Generic,
Some(LayoutReplacedKind::Image),
vec![],
)
.with_metrics(ReplacedMetrics {
intrinsic_width: Some(16.0),
intrinsic_height: Some(16.0),
..ReplacedMetrics::default()
}),
Node::text("text", "abc"),
]);
let mut styles = Styles::default();
styles.primary.insert(
0,
sized(LayoutDisplay::Block, 200.0, 100.0, PaintColor::TRANSPARENT),
);
styles.primary.insert(
1,
style(LayoutDisplay::InlineBlock, GREEN)
.tap_taffy(|style| {
style.size.width = Dimension::length(10.0);
})
.with_text_metrics(16.0, 20.0),
);
styles.primary.insert(
2,
sized(LayoutDisplay::Block, 16.0, 16.0, BLUE).with_float(Float::Left, Clear::None),
);
let snapshot = render(&source, &mut styles, 200, 100);
assert_close(rect(&snapshot, GREEN).height, 36.0);
}
#[test]
fn inline_float_wrapping_ignores_hanging_space_and_uses_the_first_fitting_band() {
for with_tall_right_float in [false, true] {
let source = Source(vec![
Node::element(
"root",
"div",
LayoutElementCategory::Generic,
None,
vec![1, 4],
),
Node::element(
"container",
"div",
LayoutElementCategory::Generic,
None,
if with_tall_right_float {
vec![2, 6, 3]
} else {
vec![2, 3]
},
),
Node::element("left", "div", LayoutElementCategory::Generic, None, vec![]),
Node::text(
"text",
if with_tall_right_float {
"abc"
} else {
"abc abc"
},
),
Node::element(
"reference",
"div",
LayoutElementCategory::Generic,
None,
vec![5],
),
Node::text("reference-text", "abc"),
Node::element("right", "div", LayoutElementCategory::Generic, None, vec![]),
]);
let mut styles = Styles::default();
styles.primary.insert(
0,
sized(LayoutDisplay::Block, 200.0, 100.0, PaintColor::TRANSPARENT),
);
styles.primary.insert(
1,
style(LayoutDisplay::InlineBlock, GREEN).with_text_metrics(16.0, 20.0),
);
styles.primary.insert(
2,
sized(LayoutDisplay::Block, 16.0, 16.0, BLUE).with_float(Float::Left, Clear::None),
);
styles.primary.insert(
4,
style(LayoutDisplay::InlineBlock, YELLOW).with_text_metrics(16.0, 20.0),
);
styles.primary.insert(
6,
sized(LayoutDisplay::Block, 16.0, 40.0, RED).with_float(Float::Right, Clear::None),
);
let reference = render(&source, &mut styles, 200, 100);
let word_width = rect(&reference, YELLOW).width;
// Paint rectangles are pixel-rounded. Leave one pixel for that
// rounding while keeping the first word's trailing space hanging.
let width = word_width + if with_tall_right_float { 28.0 } else { 17.0 };
styles.primary.insert(
1,
style(LayoutDisplay::InlineBlock, GREEN)
.tap_taffy(|style| style.size.width = Dimension::length(width))
.with_text_metrics(16.0, 20.0),
);
let snapshot = render(&source, &mut styles, 200, 100);
assert_close(rect(&snapshot, GREEN).height, 40.0);
if with_tall_right_float {
assert_close(rect(&snapshot, RED).y, rect(&snapshot, BLUE).y);
}
let first_baseline = snapshot
.fragments
.iter()
.find_map(|fragment| match fragment {
PaintFragment::GlyphRun(run) => {
run.glyphs_in_surface().first().map(|glyph| glyph.y)
}
_ => None,
})
.expect("container text");
let short_float_bottom = rect(&snapshot, BLUE).y + 16.0;
if with_tall_right_float {
assert!(
first_baseline >= short_float_bottom
&& first_baseline < rect(&snapshot, RED).y + 40.0
);
} else {
assert!(
first_baseline < short_float_bottom,
"hanging whitespace must not push a fitting word below the float"
);
}
}
}
#[test]
fn inline_block_ratio_height_respects_automatic_content_minimum_and_opt_outs() {
let source = Source(vec![
+173
View File
@@ -0,0 +1,173 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.88"
name = "parley"
version = "0.11.1"
build = false
exclude = ["/tests"]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Parley provides an API for implementing rich text layout."
readme = "README.md"
keywords = [
"text",
"layout",
]
categories = [
"gui",
"graphics",
]
license = "Apache-2.0 OR MIT"
repository = "https://github.com/linebender/parley"
[package.metadata.docs.rs]
all-features = true
[features]
accesskit = ["dep:accesskit"]
complex-scripts = []
default = ["system"]
libm = [
"fontique/libm",
"harfrust/libm",
"peniko/libm",
"skrifa/libm",
"dep:core_maths",
]
std = [
"fontique/std",
"harfrust/std",
"peniko/std",
"skrifa/std",
"parlance/std",
]
system = [
"std",
"fontique/system",
]
[lib]
name = "parley"
path = "src/lib.rs"
[dependencies.accesskit]
version = "0.24.0"
optional = true
[dependencies.core_maths]
version = "0.1.1"
optional = true
[dependencies.fontique]
version = "0.11.1"
default-features = false
[dependencies.harfrust]
version = "0.12.0"
default-features = false
[dependencies.hashbrown]
version = "0.17.0"
features = [
"default-hasher",
"raw-entry",
]
default-features = false
[dependencies.icu_normalizer]
version = "2.1.1"
features = ["compiled_data"]
default-features = false
[dependencies.icu_properties]
version = "2.1.2"
features = ["compiled_data"]
default-features = false
[dependencies.icu_segmenter]
version = "2.1.2"
features = ["compiled_data"]
default-features = false
[dependencies.linebender_resource_handle]
version = "0.1.1"
default-features = false
[dependencies.parlance]
version = "0.1.0"
default-features = false
[dependencies.parley_data]
version = "0.11.1"
features = ["baked"]
default-features = false
[dependencies.skrifa]
version = "0.44.0"
default-features = false
[dev-dependencies.peniko]
version = "0.6.0"
default-features = false
[target.'cfg(not(target_os = "android"))'.dev-dependencies.oxipng]
version = "9.1.5"
features = ["freestanding"]
default-features = false
[lints.clippy]
allow_attributes_without_reason = "warn"
cargo_common_metadata = "warn"
cast_possible_truncation = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
debug_assert_with_mut_call = "warn"
default_trait_access = "warn"
doc_markdown = "warn"
fn_to_numeric_cast_any = "warn"
infinite_loop = "warn"
large_stack_arrays = "warn"
mismatching_type_param_order = "warn"
missing_assert_message = "warn"
missing_fields_in_debug = "warn"
negative_feature_names = "warn"
redundant_feature_names = "warn"
same_functions_in_if_condition = "warn"
semicolon_if_nothing_returned = "warn"
should_panic_without_expect = "warn"
todo = "warn"
too_many_arguments = "allow"
unseparated_literal_suffix = "warn"
use_self = "warn"
wildcard_dependencies = "warn"
[lints.rust]
elided_lifetimes_in_paths = "warn"
keyword_idents_2024 = "forbid"
missing_debug_implementations = "warn"
missing_docs = "warn"
non_ascii_idents = "forbid"
non_local_definitions = "forbid"
trivial_numeric_casts = "warn"
unnameable_types = "warn"
unreachable_pub = "warn"
unsafe_code = "deny"
unsafe_op_in_unsafe_fn = "forbid"
unused_import_braces = "warn"
unused_lifetimes = "warn"
unused_macro_rules = "warn"
unused_qualifications = "warn"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+25
View File
@@ -0,0 +1,25 @@
Copyright 2020 the Parley Authors
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
# Moli patches to Parley 0.11.1
Source: the `parley` 0.11.1 crates.io package, archive SHA-256
`22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5`.
Original source and Apache-2.0 / MIT licenses are retained. Cargo cache metadata
and the standalone lockfile are not part of this vendored dependency.
`BreakLines::last_line_metrics()` exposes the last committed line's metrics
without changing the breaker. Moli uses its advance minus trailing whitespace
to retry an unbreakable word in a wider float exclusion band. Reading only
`LineBreakData::advance` would incorrectly include hanging whitespace.
Regression coverage: `moli-layout/tests/phase4_layout_contract.rs`.
Remove the local patch when a compatible upstream release exposes these metrics.
+55
View File
@@ -0,0 +1,55 @@
<div align="center">
# Parley
**Rich text layout**
[![Latest published parley version.](https://img.shields.io/crates/v/parley.svg)](https://crates.io/crates/parley)
[![Documentation build status.](https://img.shields.io/docsrs/parley.svg)](https://docs.rs/parley)
[![Dependency staleness status.](https://deps.rs/crate/parley/latest/status.svg)](https://deps.rs/crate/parley)
[![Linebender Zulip chat.](https://img.shields.io/badge/Linebender-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley)
[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license)
</div>
Parley provides an API for implementing rich text layout.
It is backed by [HarfRust](https://github.com/harfbuzz/harfrust) for text shaping.
## Minimum supported Rust Version (MSRV)
This version of Parley has been verified to compile with **Rust 1.88** and later.
Future versions of Parley might increase the Rust version requirement.
It will not be treated as a breaking change and as such can even happen with small patch releases.
<details>
<summary>Click here if compiling fails.</summary>
As time has passed, some of Parley's dependencies could have released versions with a higher Rust requirement.
If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency.
```sh
# Use the problematic dependency's name and version
cargo update -p package_name --precise 0.1.1
```
</details>
## Community
Discussion of Parley development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#parley channel](https://xi.zulipchat.com/#narrow/channel/205635-parley).
All public content can be read without logging in.
Contributions are welcome by pull request. The [Rust code of conduct] applies.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache 2.0 license, shall be licensed as noted in the [License](#license) section, without any additional terms or conditions.
## License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
at your option.
[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct
+382
View File
@@ -0,0 +1,382 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use alloc::vec::Vec;
use icu_normalizer::properties::Decomposed;
use crate::analysis::AnalysisDataSources;
/// The maximum number of characters in a single cluster.
const MAX_CLUSTER_SIZE: usize = 32;
#[derive(Debug, Default)]
pub(crate) struct CharCluster {
pub chars: Vec<Char>,
pub is_emoji: bool,
pub map_len: u8,
pub start: u32,
pub end: u32,
pub force_normalize: bool,
comp: Form,
decomp: Form,
form: FormKind,
best_ratio: f32,
}
impl CharCluster {
pub(crate) fn range(&self) -> SourceRange {
SourceRange {
start: self.start,
end: self.end,
}
}
}
/// Source range of a cluster in code units.
#[derive(Copy, Clone)]
pub(crate) struct SourceRange {
pub start: u32,
pub end: u32,
}
#[derive(Copy, Clone, Debug, Default)]
pub(crate) struct Char {
/// The character.
pub ch: char,
/// Whether the character
pub is_control_character: bool,
/// True if the character should be considered when mapping glyphs.
pub contributes_to_shaping: bool,
/// Nominal glyph identifier.
pub glyph_id: GlyphId,
/// Indexes into the list of styles for the containing text run, to find the style applicable
/// to this character.
pub style_index: u16,
}
pub(crate) type GlyphId = u16;
/// Whitespace content of a cluster.
#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug)]
#[repr(u8)]
pub(crate) enum Whitespace {
/// Not a space.
None = 0,
/// Standard space.
Space = 1,
/// Non-breaking space (U+00A0).
NoBreakSpace = 2,
/// Horizontal tab.
Tab = 3,
/// Newline (CR, LF, CRLF, LS, or PS).
Newline = 4,
}
impl Whitespace {
/// Returns true for space or no break space.
pub(crate) fn is_space_or_nbsp(self) -> bool {
matches!(self, Self::Space | Self::NoBreakSpace)
}
}
/// Iterative status of mapping a character cluster to nominal glyph identifiers.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub(crate) enum Status {
/// Mapping should be skipped.
Discard,
/// The best mapping so far.
Keep,
/// Complete mapping.
Complete,
}
impl CharCluster {
pub(crate) fn clear(&mut self) {
self.chars.clear();
self.is_emoji = false;
self.map_len = 0;
self.start = 0;
self.end = 0;
self.force_normalize = false;
self.comp.clear();
self.decomp.clear();
self.form = FormKind::Original;
self.best_ratio = 0.;
}
#[inline(always)]
fn len(&self) -> usize {
self.chars.len()
}
/// Returns the primary style index for the cluster.
#[inline(always)]
pub(crate) fn style_index(&self) -> u16 {
self.chars[0].style_index
}
#[inline(always)]
fn contributes_to_shaping(ch: char, analysis_data_sources: &AnalysisDataSources) -> bool {
let props = analysis_data_sources.properties(ch);
crate::analysis::contributes_to_shaping(props.general_category(), props.script())
}
fn decomposed(&mut self, analysis_data_sources: &AnalysisDataSources) -> Option<&[Char]> {
match self.decomp.state {
FormState::Invalid => None,
FormState::None => {
self.decomp.state = FormState::Invalid;
// Only attempt pairwise normalization (1 <-> 2 characters)
if self.chars.len() != 1 {
return None;
}
let decomposer = analysis_data_sources.decomposing_normalizer();
let decomp = decomposer.decompose(self.chars[0].ch);
match decomp {
Decomposed::Default | Decomposed::Singleton(_) => {
return None;
}
Decomposed::Expansion(a, b) => {
let mut copy = self.chars[0];
copy.ch = a;
copy.contributes_to_shaping =
Self::contributes_to_shaping(a, analysis_data_sources);
self.decomp.chars[0] = copy;
copy.ch = b;
copy.contributes_to_shaping =
Self::contributes_to_shaping(b, analysis_data_sources);
self.decomp.chars[1] = copy;
self.decomp.len = 2;
}
}
self.decomp.state = FormState::Valid;
self.decomp.setup();
Some(self.decomp.chars())
}
FormState::Valid => Some(self.decomp.chars()),
}
}
fn composed(&mut self, analysis_data_sources: &AnalysisDataSources) -> Option<&[Char]> {
match self.comp.state {
FormState::Invalid => None,
FormState::None => {
self.comp.state = FormState::Invalid;
// Only attempt pairwise normalization (1 <-> 2 characters)
if self.chars.len() != 2 {
return None;
}
let composer = analysis_data_sources.composing_normalizer();
let comp = composer.compose(self.chars[0].ch, self.chars[1].ch);
match comp {
None => {}
Some(ch) => {
let mut copy = self.chars[0];
copy.ch = ch;
copy.contributes_to_shaping =
Self::contributes_to_shaping(ch, analysis_data_sources);
self.comp.chars[0] = copy;
self.comp.len = 1;
}
}
self.comp.state = FormState::Valid;
self.comp.setup();
Some(self.comp.chars())
}
FormState::Valid => Some(self.comp.chars()),
}
}
pub(crate) fn map(
&mut self,
f: impl Fn(char) -> GlyphId,
analysis_data_sources: &AnalysisDataSources,
) -> Status {
let len = self.len();
if len == 0 {
return Status::Complete;
}
let mut glyph_ids = [0_u16; MAX_CLUSTER_SIZE];
let prev_ratio = self.best_ratio;
let mut ratio;
if self.force_normalize && self.composed(analysis_data_sources).is_some() {
ratio = self.comp.map(&f, &mut glyph_ids, self.best_ratio);
if ratio > self.best_ratio {
self.best_ratio = ratio;
self.form = FormKind::NFC;
if ratio >= 1. {
return Status::Complete;
}
}
}
ratio = Mapper {
chars: &mut self.chars[..len],
map_len: self.map_len.max(1),
has_contributing: self.map_len > 0,
}
.map(&f, &mut glyph_ids, self.best_ratio);
if ratio > self.best_ratio {
self.best_ratio = ratio;
self.form = FormKind::Original;
if ratio >= 1. {
return Status::Complete;
}
}
if self.decomposed(analysis_data_sources).is_some() {
ratio = self.decomp.map(&f, &mut glyph_ids, self.best_ratio);
if ratio > self.best_ratio {
self.best_ratio = ratio;
self.form = FormKind::NFD;
if ratio >= 1. {
return Status::Complete;
}
}
if !self.force_normalize && self.composed(analysis_data_sources).is_some() {
ratio = self.comp.map(&f, &mut glyph_ids, self.best_ratio);
if ratio > self.best_ratio {
self.best_ratio = ratio;
self.form = FormKind::NFC;
if ratio >= 1. {
return Status::Complete;
}
}
}
}
if self.best_ratio > prev_ratio {
Status::Keep
} else {
Status::Discard
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
#[allow(clippy::upper_case_acronyms)]
enum FormKind {
#[default]
Original,
NFD,
NFC,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum FormState {
None,
Valid,
Invalid,
}
#[derive(Clone, Debug)]
pub(crate) struct Form {
chars: [Char; 2],
len: u8,
map_len: u8,
has_contributing: bool,
state: FormState,
}
impl Default for Form {
fn default() -> Self {
Self::new()
}
}
impl Form {
fn new() -> Self {
Self {
chars: [Char::default(), Char::default()],
len: 0,
map_len: 0,
has_contributing: false,
state: FormState::None,
}
}
fn clear(&mut self) {
self.chars = [Char::default(), Char::default()];
self.len = 0;
self.map_len = 0;
self.has_contributing = false;
self.state = FormState::None;
}
#[inline(always)]
fn chars(&self) -> &[Char] {
&self.chars[..self.len as usize]
}
#[inline(always)]
fn setup(&mut self) {
self.map_len = (self
.chars()
.iter()
.filter(|c| !c.is_control_character)
.count() as u8)
.max(1);
self.has_contributing = self.chars().iter().any(|c| c.contributes_to_shaping);
}
#[inline(always)]
fn map(
&mut self,
f: &impl Fn(char) -> u16,
glyphs: &mut [u16; MAX_CLUSTER_SIZE],
best_ratio: f32,
) -> f32 {
Mapper {
chars: &mut self.chars[..self.len as usize],
map_len: self.map_len,
has_contributing: self.has_contributing,
}
.map(f, glyphs, best_ratio)
}
}
struct Mapper<'a> {
chars: &'a mut [Char],
map_len: u8,
has_contributing: bool,
}
impl<'a> Mapper<'a> {
fn map(
&mut self,
f: &impl Fn(char) -> u16,
glyphs: &mut [u16; MAX_CLUSTER_SIZE],
best_ratio: f32,
) -> f32 {
if self.map_len == 0 {
return 1.;
}
let mut mapped = 0;
for (c, g) in self.chars.iter().zip(glyphs.iter_mut()) {
if !c.contributes_to_shaping {
*g = f(c.ch);
if !self.has_contributing {
mapped += 1;
}
} else {
let gid = f(c.ch);
*g = gid;
if gid != 0 {
mapped += 1;
}
}
}
let ratio = mapped as f32 / self.map_len as f32;
if ratio > best_ratio {
for (ch, glyph) in self.chars.iter_mut().zip(glyphs) {
ch.glyph_id = *glyph;
}
}
ratio
}
}
+565
View File
@@ -0,0 +1,565 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
pub(crate) mod cluster;
use alloc::vec::Vec;
use core::marker::PhantomData;
use crate::break_overrides::{LineBreakContext, LineBreakOverrideFn};
use crate::resolve::StyleRun;
use crate::{Brush, LayoutContext, WordBreak};
use icu_normalizer::properties::{
CanonicalComposition, CanonicalCompositionBorrowed, CanonicalDecomposition,
CanonicalDecompositionBorrowed,
};
use icu_properties::props::{BidiMirroringGlyph, GeneralCategory, GraphemeClusterBreak, Script};
use icu_properties::{
CodePointMapData, CodePointMapDataBorrowed, PropertyNamesShort, PropertyNamesShortBorrowed,
};
use icu_segmenter::options::{LineBreakOptions, LineBreakWordOption, WordBreakInvariantOptions};
use icu_segmenter::{
GraphemeClusterSegmenter, GraphemeClusterSegmenterBorrowed, LineSegmenter,
LineSegmenterBorrowed, WordSegmenter, WordSegmenterBorrowed,
};
use parley_data::Properties;
pub(crate) struct AnalysisDataSources;
impl AnalysisDataSources {
pub(crate) fn new() -> Self {
Self
}
#[inline(always)]
pub(crate) fn properties(&self, c: char) -> Properties {
Properties::get(c)
}
#[inline(always)]
pub(crate) fn grapheme_segmenter(&self) -> GraphemeClusterSegmenterBorrowed<'_> {
const { GraphemeClusterSegmenter::new() }
}
#[inline(always)]
fn word_segmenter(&self) -> WordSegmenterBorrowed<'static> {
#[cfg(feature = "complex-scripts")]
{
WordSegmenter::new_dictionary(WordBreakInvariantOptions::default())
}
#[cfg(not(feature = "complex-scripts"))]
{
const { WordSegmenter::new_for_non_complex_scripts(WordBreakInvariantOptions::default()) }
}
}
#[inline(always)]
fn line_segmenter(&self, word_break_strength: WordBreak) -> LineSegmenterBorrowed<'static> {
match word_break_strength {
WordBreak::Normal => {
let mut opt = LineBreakOptions::default();
opt.word_option = Some(LineBreakWordOption::Normal);
line_segmenter_impl(opt)
}
WordBreak::BreakAll => {
let mut opt = LineBreakOptions::default();
opt.word_option = Some(LineBreakWordOption::BreakAll);
line_segmenter_impl(opt)
}
WordBreak::KeepAll => {
let mut opt = LineBreakOptions::default();
opt.word_option = Some(LineBreakWordOption::KeepAll);
line_segmenter_impl(opt)
}
}
}
#[inline(always)]
fn composing_normalizer(&self) -> CanonicalCompositionBorrowed<'_> {
const { CanonicalComposition::new() }
}
#[inline(always)]
fn decomposing_normalizer(&self) -> CanonicalDecompositionBorrowed<'_> {
const { CanonicalDecomposition::new() }
}
#[inline(always)]
pub(crate) fn script_short_name(&self) -> PropertyNamesShortBorrowed<'static, Script> {
PropertyNamesShort::new()
}
#[inline(always)]
fn brackets(&self) -> CodePointMapDataBorrowed<'_, BidiMirroringGlyph> {
const { CodePointMapData::new() }
}
}
#[cfg(feature = "complex-scripts")]
#[inline(always)]
fn line_segmenter_impl(opt: LineBreakOptions<'_>) -> LineSegmenterBorrowed<'static> {
LineSegmenter::new_dictionary(opt)
}
#[cfg(not(feature = "complex-scripts"))]
#[inline(always)]
fn line_segmenter_impl(opt: LineBreakOptions<'_>) -> LineSegmenterBorrowed<'static> {
LineSegmenter::new_for_non_complex_scripts(opt)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) struct CharInfo {
/// The line/word breaking boundary classification of this character.
pub boundary: Boundary,
/// The Unicode script this character belongs to.
pub script: Script,
/// The grapheme cluster boundary property of this character.
pub grapheme_cluster_break: GraphemeClusterBreak,
/// The impact this character has on directionality.
pub bidi_class: icu_properties::props::BidiClass,
/// Whether or not the character is a bracket, plus mirror data if so.
pub bracket: BidiMirroringGlyph,
flags: u8,
}
impl CharInfo {
const VARIATION_SELECTOR_SHIFT: u8 = 0;
const REGION_INDICATOR_SHIFT: u8 = 1;
const CONTROL_SHIFT: u8 = 2;
const EMOJI_OR_PICTOGRAPH_SHIFT: u8 = 3;
const CONTRIBUTES_TO_SHAPING_SHIFT: u8 = 4;
const FORCE_NORMALIZE_SHIFT: u8 = 5;
#[allow(
dead_code,
reason = "To be used in more complete emoji checking, in select_font"
)]
const VARIATION_SELECTOR_MASK: u8 = 1 << Self::VARIATION_SELECTOR_SHIFT;
#[allow(
dead_code,
reason = "To be used in more complete emoji checking, in select_font"
)]
const REGION_INDICATOR_MASK: u8 = 1 << Self::REGION_INDICATOR_SHIFT;
const CONTROL_MASK: u8 = 1 << Self::CONTROL_SHIFT;
const EMOJI_OR_PICTOGRAPH_MASK: u8 = 1 << Self::EMOJI_OR_PICTOGRAPH_SHIFT;
const CONTRIBUTES_TO_SHAPING_MASK: u8 = 1 << Self::CONTRIBUTES_TO_SHAPING_SHIFT;
const FORCE_NORMALIZE_MASK: u8 = 1 << Self::FORCE_NORMALIZE_SHIFT;
fn new(
boundary: Boundary,
script: Script,
grapheme_cluster_break: GraphemeClusterBreak,
bidi_class: icu_properties::props::BidiClass,
bracket: BidiMirroringGlyph,
is_variation_selector: bool,
is_region_indicator: bool,
is_control: bool,
is_emoji_or_pictograph: bool,
contributes_to_shaping: bool,
force_normalize: bool,
) -> Self {
Self {
boundary,
script,
grapheme_cluster_break,
bidi_class,
bracket,
flags: (is_variation_selector as u8) << Self::VARIATION_SELECTOR_SHIFT
| (is_region_indicator as u8) << Self::REGION_INDICATOR_SHIFT
| (is_control as u8) << Self::CONTROL_SHIFT
| (is_emoji_or_pictograph as u8) << Self::EMOJI_OR_PICTOGRAPH_SHIFT
| (contributes_to_shaping as u8) << Self::CONTRIBUTES_TO_SHAPING_SHIFT
| (force_normalize as u8) << Self::FORCE_NORMALIZE_SHIFT,
}
}
#[allow(
dead_code,
reason = "To be used in more complete emoji checking, in select_font"
)]
#[inline(always)]
pub(crate) fn is_variation_selector(self) -> bool {
self.flags & Self::VARIATION_SELECTOR_MASK != 0
}
#[allow(
dead_code,
reason = "To be used in more complete emoji checking, in select_font"
)]
#[inline(always)]
pub(crate) fn is_region_indicator(self) -> bool {
self.flags & Self::REGION_INDICATOR_MASK != 0
}
#[inline(always)]
pub(crate) fn is_control(self) -> bool {
self.flags & Self::CONTROL_MASK != 0
}
#[inline(always)]
pub(crate) fn is_emoji_or_pictograph(self) -> bool {
self.flags & Self::EMOJI_OR_PICTOGRAPH_MASK != 0
}
#[inline(always)]
pub(crate) fn contributes_to_shaping(self) -> bool {
self.flags & Self::CONTRIBUTES_TO_SHAPING_MASK != 0
}
#[inline(always)]
pub(crate) fn force_normalize(self) -> bool {
self.flags & Self::FORCE_NORMALIZE_MASK != 0
}
}
/// Boundary type of a character or cluster.
#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug)]
#[repr(u8)]
pub(crate) enum Boundary {
/// Not a boundary.
None = 0,
/// Start of a word.
Word = 1,
/// Potential line break.
Line = 2,
/// Mandatory line break.
Mandatory = 3,
}
pub(crate) fn analyze_text<B: Brush>(
lcx: &mut LayoutContext<B>,
mut text: &str,
line_break_override: Option<&LineBreakOverrideFn>,
) {
struct WordBreakSegmentIter<'a, I: Iterator, B: Brush> {
text: &'a str,
style_runs: I,
lcx: &'a LayoutContext<B>,
char_indices: core::str::CharIndices<'a>,
current_char: (usize, char),
building_range_start: usize,
previous_word_break_style: WordBreak,
done: bool,
_phantom: PhantomData<B>,
}
impl<'a, I, B: Brush + 'a> WordBreakSegmentIter<'a, I, B>
where
I: Iterator<Item = &'a StyleRun>,
{
fn new(
text: &'a str,
style_runs: I,
lcx: &'a LayoutContext<B>,
first_style_run: &StyleRun,
) -> Self {
let mut char_indices = text.char_indices();
let current_char_len = char_indices.next().unwrap();
let first_style = &lcx.style_table[first_style_run.style_index as usize];
Self {
text,
style_runs,
lcx,
char_indices,
current_char: current_char_len,
building_range_start: first_style_run.range.start,
previous_word_break_style: first_style.word_break,
done: false,
_phantom: PhantomData,
}
}
}
impl<'a, I, B: Brush + 'a> Iterator for WordBreakSegmentIter<'a, I, B>
where
I: Iterator<Item = &'a StyleRun>,
{
type Item = (&'a str, WordBreak, bool);
fn next(&mut self) -> Option<Self::Item> {
if self.done {
return None;
}
for style_run in self.style_runs.by_ref() {
// Empty style ranges are disallowed.
assert!(style_run.range.start < style_run.range.end);
let style_start_index = style_run.range.start;
let mut prev_char_index = self.current_char;
// Find the character at the style boundary
while self.current_char.0 < style_start_index {
prev_char_index = self.current_char;
self.current_char = self.char_indices.next().unwrap();
}
let current_word_break_style =
self.lcx.style_table[style_run.style_index as usize].word_break;
if self.previous_word_break_style == current_word_break_style {
continue;
}
// Produce one substring for each different word break style run
let prev_size = prev_char_index.1.len_utf8();
let size = self.current_char.1.len_utf8();
let substring = &self.text[self.building_range_start..style_start_index + size];
let result_style = self.previous_word_break_style;
self.building_range_start = style_start_index - prev_size;
self.previous_word_break_style = current_word_break_style;
return Some((substring, result_style, false));
}
// Final segment
self.done = true;
let last_substring = &self.text[self.building_range_start..self.text.len()];
Some((last_substring, self.previous_word_break_style, true))
}
}
if text.is_empty() {
text = " ";
}
// Line boundaries (word break naming refers to the line boundary determination config).
//
// This breaks text into sequences with similar line boundary config (part of style
// information). If this config is consistent for all text, we use a fast path through this.
let (first_style_run, rest_runs) = lcx
.style_runs
.split_first()
.expect("analyze_text requires at least one style run");
let contiguous_word_break_substrings =
WordBreakSegmentIter::new(text, rest_runs.iter(), lcx, first_style_run);
let mut global_offset = 0;
let mut line_boundary_positions: Vec<usize> = Vec::new();
for (substring_index, (substring, word_break_strength, last)) in
contiguous_word_break_substrings.enumerate()
{
// Fast path for text with a single word-break option.
if substring_index == 0 && last {
let mut lb_iter = lcx
.analysis_data_sources
.line_segmenter(word_break_strength)
.segment_str(substring);
let _first = lb_iter.next();
let second = lb_iter.next();
if second.is_none() {
continue;
}
let third = lb_iter.next();
if third.is_none() {
continue;
}
let iter = [second.unwrap(), third.unwrap()].into_iter().chain(lb_iter);
line_boundary_positions.extend(iter);
// Remove the unnecessary boundary at the end added by ICU4X.
line_boundary_positions.pop();
break;
}
let line_boundaries_iter = lcx
.analysis_data_sources
.line_segmenter(word_break_strength)
.segment_str(substring);
let mut substring_chars = substring.chars();
if substring_index != 0 {
global_offset -= substring_chars.next().unwrap().len_utf8();
}
// There will always be at least two characters if we are not taking the fast path for
// a single word break style substring.
let last_len = substring_chars.next_back().unwrap().len_utf8();
// Mark line boundaries (overriding word boundaries where present).
for (index, pos) in line_boundaries_iter.enumerate() {
// icu adds leading and trailing line boundaries, which we don't use.
if index == 0 || pos == substring.len() {
continue;
}
// For all but the last substring, we ignore line boundaries caused by the last
// character, as this character is carried back from the next substring, and will be
// accounted for there.
if !last && pos == substring.len() - last_len {
continue;
}
line_boundary_positions.push(pos + global_offset);
}
if !last {
global_offset += substring.len() - last_len;
}
}
// Collect boundary byte positions compactly
let mut wb_iter = lcx
.analysis_data_sources
.word_segmenter()
.segment_str(text)
.peekable();
// Merge boundaries - line takes precedence over word
let mut lb_iter = line_boundary_positions.iter().peekable();
let mut prev_char = None;
let mut prev_prev_char = None;
let boundary_iter = text.char_indices().map(|(byte_pos, ch)| {
// advance any stale word boundary positions
while let Some(&w) = wb_iter.peek() {
if w < byte_pos {
_ = wb_iter.next();
} else {
break;
}
}
// advance any stale line boundary positions
while let Some(&l) = lb_iter.peek() {
if *l < byte_pos {
_ = lb_iter.next();
} else {
break;
}
}
let mut is_word = false;
if let Some(&w) = wb_iter.peek() {
if w == byte_pos {
is_word = true;
_ = wb_iter.next();
}
}
let mut is_line = false;
if let Some(&l) = lb_iter.peek() {
if *l == byte_pos {
is_line = true;
_ = lb_iter.next();
}
}
// This leaves word boundaries intact. Consumers can only impact line boundaries.
if let (Some(prev), Some(lb_override)) = (prev_char, line_break_override) {
let forced = lb_override(LineBreakContext {
before_before: prev_prev_char,
before: prev,
after: ch,
});
if let Some(forced) = forced {
is_line = forced;
}
}
prev_prev_char = prev_char;
prev_char = Some(ch);
let boundary = if is_line {
Boundary::Line
} else if is_word {
Boundary::Word
} else {
Boundary::None
};
(boundary, ch)
});
let properties = |c| lcx.analysis_data_sources.properties(c);
let mut needs_bidi_resolution = false;
lcx.info.reserve(text.len());
boundary_iter
// Shift line break data forward one, as line boundaries corresponding with line-breaking
// characters (like '\n') exist at an index position one higher than the respective
// character's index, but we need our iterators to align, and the rest are simply
// character-indexed.
.fold(false, |is_mandatory_linebreak, (boundary, ch)| {
let properties = properties(ch);
let script = properties.script();
let grapheme_cluster_break = properties.grapheme_cluster_break();
let bidi_class = properties.bidi_class();
let general_category = properties.general_category();
let is_emoji_or_pictograph = properties.is_emoji_or_pictograph();
let is_variation_selector = properties.is_variation_selector();
let is_region_indicator = properties.is_region_indicator();
let next_mandatory_linebreak = properties.is_mandatory_linebreak();
let boundary = if is_mandatory_linebreak {
Boundary::Mandatory
} else {
boundary
};
let force_normalize = {
// "Extend" break chars should be normalized first, with two exceptions
if matches!(grapheme_cluster_break, GraphemeClusterBreak::Extend) &&
ch as u32 != 0x200C && // Is not a Zero Width Non-Joiner &&
!is_variation_selector
{
true
} else {
// All spacing mark break chars should be normalized first.
matches!(grapheme_cluster_break, GraphemeClusterBreak::SpacingMark)
}
};
needs_bidi_resolution |= crate::bidi::needs_bidi_resolution(bidi_class);
// TODO: maybe extend Properties to u64 to fit BidiMirroringGlyph
let bracket = lcx.analysis_data_sources.brackets().get(ch);
lcx.info.push((
CharInfo::new(
boundary,
script,
grapheme_cluster_break,
bidi_class,
bracket,
is_variation_selector,
is_region_indicator,
general_category == GeneralCategory::Control,
is_emoji_or_pictograph,
contributes_to_shaping(general_category, script),
force_normalize,
),
0, // Style index is populated later
));
next_mandatory_linebreak
});
if needs_bidi_resolution {
lcx.bidi.resolve(
text.chars().zip(
lcx.info
.iter()
.map(|info| (info.0.bidi_class, info.0.bracket)),
),
None,
);
}
}
/// All characters contribute to shaping except:
/// - Control characters
/// - Format characters, unless they use the "Inherited" script
#[inline(always)]
pub(crate) fn contributes_to_shaping(general_category: GeneralCategory, script: Script) -> bool {
if matches!(
general_category,
GeneralCategory::Control
| GeneralCategory::LineSeparator
| GeneralCategory::ParagraphSeparator
) {
return false;
}
!(general_category == GeneralCategory::Format && script != Script::Inherited)
}
+894
View File
@@ -0,0 +1,894 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Unicode bidirectional algorithm.
use alloc::vec::Vec;
use icu_properties::props::{BidiClass, BidiMirroringGlyph, BidiPairedBracketType};
/// Type alias for a bidirectional level.
pub(crate) type BidiLevel = u8;
/// Resolver for the Unicode bidirectional algorithm.
#[derive(Clone, Default)]
pub(crate) struct BidiResolver {
base_level: BidiLevel,
levels: Vec<BidiLevel>,
initial_types: Vec<BidiClass>,
types: Vec<BidiClass>,
brackets: Vec<(usize, char, BidiMirroringGlyph)>,
bracket_pairs: Vec<(usize, usize)>,
runs: Vec<Run>,
indices: Vec<usize>,
flags: u16,
}
impl BidiResolver {
/// Creates a new resolver.
pub(crate) fn new() -> Self {
Self {
base_level: 0,
levels: Vec::new(),
initial_types: Vec::new(),
types: Vec::new(),
brackets: Vec::new(),
bracket_pairs: Vec::new(),
runs: Vec::new(),
indices: Vec::new(),
flags: 0,
}
}
/// Returns the base level of the text.
pub(crate) fn base_level(&self) -> u8 {
self.base_level
}
/// Returns the sequence of bidi levels corresponding to all characters in the
/// paragraph.
pub(crate) fn levels(&self) -> &[BidiLevel] {
&self.levels
}
/// Clears the resolver state.
pub(crate) fn clear(&mut self) {
self.initial_types.clear();
self.levels.clear();
self.types.clear();
self.brackets.clear();
self.bracket_pairs.clear();
self.flags = 0;
self.base_level = 0;
}
/// Resolves a paragraph with the specified base direction and
/// precomputed types.
pub(crate) fn resolve(
&mut self,
chars: impl Iterator<Item = (char, (BidiClass, BidiMirroringGlyph))>,
base_level: Option<u8>,
) {
self.clear();
let mut needs_bidi = false;
let mut len = 0;
for (i, (ch, (t, bracket))) in chars.enumerate() {
self.initial_types.push(t);
if bracket.paired_bracket_type != BidiPairedBracketType::None {
self.brackets.push((i, ch, bracket));
}
needs_bidi = needs_bidi || mask(t) & BIDI_MASK != 0;
len += 1;
}
self.base_level = match base_level {
Some(level) => level & 1,
_ => Self::default_level(&self.initial_types),
};
if !needs_bidi && self.base_level == 0 {
self.flags |= 1;
self.levels.resize(len, self.base_level);
return;
}
self.types.extend_from_slice(&self.initial_types);
self.resolve_levels();
self.resolve_runs();
//self.dump_sequences();
for i in 0..self.runs.len() {
if self.runs[i].in_sequence {
continue;
}
self.types.truncate(len);
self.indices.clear();
let mut cur = i;
let level = self.runs[i].level;
let sos = self.runs[i].sos;
let mut eos;
loop {
let run = &self.runs[cur];
for i in run.start..run.end {
let ty = self.types[i];
if !is_removed_by_x9(ty) {
self.types.push(ty);
self.indices.push(i);
}
}
eos = run.eos;
cur = match run.next {
Some(i) => i,
None => break,
};
}
self.resolve_sequence(level, sos, eos, self.indices.len());
}
for i in 0..len {
let t = self.initial_types[i];
if t == BidiClass::SegmentSeparator || t == BidiClass::ParagraphSeparator {
self.levels[i] = self.base_level;
for j in (0..i).rev() {
let t = self.initial_types[j];
if is_removed_by_x9(t) {
continue;
} else if t == BidiClass::WhiteSpace
|| is_isolate_initiator(t)
|| t == BidiClass::PopDirectionalIsolate
{
self.levels[j] = self.base_level;
} else {
break;
}
}
} else if is_removed_by_x9(t) {
if i == 0 {
self.levels[i] = self.base_level;
} else {
self.levels[i] = self.levels[i - 1];
}
//self.levels[i] = 0xFF;
}
}
for i in (0..len).rev() {
let t = self.initial_types[i];
if is_removed_by_x9(t) {
continue;
} else if t == BidiClass::WhiteSpace
|| is_isolate_initiator(t)
|| t == BidiClass::PopDirectionalIsolate
{
//self.levels[i] = self.base_level;
} else {
break;
}
}
}
fn default_level(types: &[BidiClass]) -> u8 {
let mut isolates = 0;
for ty in types {
let ty = *ty;
match ty {
BidiClass::RightToLeftIsolate
| BidiClass::LeftToRightIsolate
| BidiClass::FirstStrongIsolate => isolates += 1,
BidiClass::PopDirectionalIsolate if isolates > 0 => isolates -= 1,
BidiClass::LeftToRight | BidiClass::RightToLeft | BidiClass::ArabicLetter
if isolates == 0 =>
{
return if ty == BidiClass::LeftToRight { 0 } else { 1 };
}
_ => {}
}
}
0
}
fn default_level_until_pdi(types: &[BidiClass]) -> u8 {
let mut isolates = 0;
for ty in types {
let ty = *ty;
match ty {
BidiClass::RightToLeftIsolate
| BidiClass::LeftToRightIsolate
| BidiClass::FirstStrongIsolate => isolates += 1,
BidiClass::PopDirectionalIsolate => {
if isolates > 0 {
isolates -= 1;
} else {
return 0;
}
}
BidiClass::LeftToRight | BidiClass::RightToLeft | BidiClass::ArabicLetter
if isolates == 0 =>
{
return if ty == BidiClass::LeftToRight { 0 } else { 1 };
}
_ => {}
}
}
0
}
fn resolve_levels(&mut self) {
let base = self.base_level;
let len = self.types.len();
self.levels.clear();
self.levels.resize(len, 0);
let mut stack = Stack::new();
let mut overflow_isolates = 0;
let mut overflow_embedding = 0;
let mut valid_isolates = 0;
stack.push(base, BidiClass::OtherNeutral, false);
for i in 0..len {
let t = self.types[i];
let tmask = mask(t);
if tmask & EXPLICIT_MASK != 0 {
let is_isolate = tmask & ISOLATE_MASK != 0;
let is_rtl = if t == BidiClass::FirstStrongIsolate && i + 1 < len {
Self::default_level_until_pdi(&self.types[i + 1..]) == 1
} else {
tmask & RTL_MASK != 0
};
if is_isolate {
self.levels[i] = stack.embedding_level();
let os = stack.override_status();
if os != BidiClass::OtherNeutral {
self.types[i] = os;
}
}
let new_level = if is_rtl {
(stack.embedding_level() + 1) | 1
} else {
(stack.embedding_level() + 2) & !1
};
if new_level <= MAX_STACK as u8 && overflow_isolates == 0 && overflow_embedding == 0
{
if is_isolate {
valid_isolates += 1;
}
stack.push(
new_level,
if t == BidiClass::LeftToRightOverride {
BidiClass::LeftToRight
} else if t == BidiClass::RightToLeftOverride {
BidiClass::RightToLeft
} else {
BidiClass::OtherNeutral
},
is_isolate,
);
} else if is_isolate {
overflow_isolates += 1;
} else if overflow_isolates == 0 {
overflow_embedding += 1;
}
} else if t == BidiClass::PopDirectionalIsolate {
if overflow_isolates > 0 {
overflow_isolates -= 1;
} else if valid_isolates == 0 {
// empty
} else {
overflow_embedding = 0;
while !stack.isolate_status() {
stack.pop();
}
stack.pop();
valid_isolates -= 1;
}
self.levels[i] = stack.embedding_level();
if stack.override_status() != BidiClass::OtherNeutral {
self.types[i] = stack.override_status();
}
} else if t == BidiClass::PopDirectionalFormat {
self.levels[i] = stack.embedding_level();
if overflow_isolates > 0 {
// empty
} else if overflow_embedding > 0 {
overflow_embedding -= 1;
} else if !stack.isolate_status() && stack.depth >= 2 {
stack.pop();
}
} else if t == BidiClass::ParagraphSeparator {
stack.depth = 1;
overflow_isolates = 0;
overflow_embedding = 0;
valid_isolates = 0;
self.levels[i] = base;
} else if t != BidiClass::BoundaryNeutral {
self.levels[i] = stack.embedding_level();
if stack.override_status() != BidiClass::OtherNeutral {
self.types[i] = stack.override_status();
}
}
}
}
fn resolve_runs(&mut self) {
let len = self.types.len();
self.runs.clear();
let mut start = 0;
while start < len {
if !is_removed_by_x9(self.types[start]) {
break;
}
start += 1;
}
if start == len {
return;
}
let mut level = self.levels[start];
let mut offset = 0;
for i in start + 1..len {
if is_removed_by_x9(self.types[i]) {
continue;
}
if self.levels[i] != level {
self.runs.push(Run::new(level, offset, i));
offset = i;
level = self.levels[i];
}
}
if offset < len {
self.runs.push(Run::new(level, offset, len));
}
for run in &mut self.runs {
while run.start < run.end {
if is_removed_by_x9(self.types[run.start]) {
run.start += 1;
} else {
break;
}
}
while run.end > run.start {
if is_removed_by_x9(self.types[run.end - 1]) {
run.end -= 1;
} else {
break;
}
}
if run.start == run.end {
continue;
}
if self.types[run.start] == BidiClass::PopDirectionalIsolate {
run.starts_with_pdi = true;
}
let mut prev_level = self.base_level;
for i in (0..run.start).rev() {
if !is_removed_by_x9(self.types[i]) {
prev_level = self.levels[i];
break;
}
}
run.sos = type_from_level(prev_level.max(run.level));
if is_isolate_initiator(self.initial_types[run.end - 1]) {
run.ends_with_isolate = true;
run.eos = type_from_level(self.base_level.max(run.level));
} else {
let mut next_level = self.base_level;
for i in run.end..len {
if !is_removed_by_x9(self.types[i]) {
next_level = self.levels[i];
break;
}
}
run.eos = type_from_level(next_level.max(run.level));
}
}
for i in 0..self.runs.len() {
if self.runs[i].ends_with_isolate {
let level = self.runs[i].level;
for j in i + 1..self.runs.len() {
if self.runs[j].starts_with_pdi && self.runs[j].level == level {
self.runs[i].next = Some(j);
self.runs[j].in_sequence = true;
break;
}
}
}
}
}
#[allow(clippy::needless_range_loop)]
fn resolve_sequence(&mut self, level: u8, sos: BidiClass, eos: BidiClass, len: usize) {
if len == 0 {
return;
}
const W1_MASK: u32 = mask(BidiClass::LeftToRightIsolate)
| mask(BidiClass::RightToLeftIsolate)
| mask(BidiClass::FirstStrongIsolate)
| mask(BidiClass::PopDirectionalIsolate);
const W2_MASK: u32 = mask(BidiClass::LeftToRight)
| mask(BidiClass::RightToLeft)
| mask(BidiClass::ArabicLetter);
const W4_MASK: u32 = mask(BidiClass::EuropeanSeparator) | mask(BidiClass::CommonSeparator);
let mut prev = sos;
let mut prev_strong = prev;
let types = &mut self.types[self.initial_types.len()..];
for i in 0..len {
let mut t = types[i];
let tmask = mask(t);
if t == BidiClass::NonspacingMark {
// W1
types[i] = prev;
} else {
if tmask & W1_MASK != 0 {
prev = BidiClass::OtherNeutral;
continue;
}
if t == BidiClass::EuropeanNumber {
// W2
if prev_strong == BidiClass::ArabicLetter {
t = BidiClass::ArabicNumber;
types[i] = t;
}
} else if tmask & W2_MASK != 0 {
prev_strong = t;
// W3
if t == BidiClass::ArabicLetter {
t = BidiClass::RightToLeft;
types[i] = t;
}
} else if tmask & W4_MASK != 0 && i < (len - 1) {
// W4
let mut next = types[i + 1];
if next == BidiClass::EuropeanNumber && prev_strong == BidiClass::ArabicLetter {
next = BidiClass::ArabicNumber;
}
if prev == BidiClass::EuropeanNumber && next == BidiClass::EuropeanNumber {
t = BidiClass::EuropeanNumber;
types[i] = t;
} else if t == BidiClass::CommonSeparator
&& prev == BidiClass::ArabicNumber
&& next == BidiClass::ArabicNumber
{
t = BidiClass::ArabicNumber;
types[i] = t;
}
}
prev = t;
}
}
// W5
let mut i = 0;
while i < len {
if types[i] == BidiClass::EuropeanTerminator {
let limit = find_limit(types, i, BidiClass::EuropeanTerminator);
let mut t = if i == 0 { sos } else { types[i - 1] };
if t != BidiClass::EuropeanNumber {
t = if limit == len { eos } else { types[limit] };
}
if t == BidiClass::EuropeanNumber {
for j in i..limit {
types[j] = BidiClass::EuropeanNumber;
}
}
i = limit;
}
i += 1;
}
// W6, W7
const W6_MASK: u32 = mask(BidiClass::EuropeanSeparator)
| mask(BidiClass::EuropeanTerminator)
| mask(BidiClass::CommonSeparator);
prev_strong = sos;
for i in 0..len {
let t = types[i];
if mask(t) & W6_MASK != 0 {
// W6
types[i] = BidiClass::OtherNeutral;
} else if t == BidiClass::EuropeanNumber {
// W7
if prev_strong == BidiClass::LeftToRight {
types[i] = BidiClass::LeftToRight;
}
} else if t == BidiClass::LeftToRight || t == BidiClass::RightToLeft {
prev_strong = t;
}
}
// N0
if !self.brackets.is_empty() {
let base_brackets = self.bracket_pairs.len();
let mut bracket_stack = BracketStack::new();
for i in 0..len {
if types[i] != BidiClass::OtherNeutral {
continue;
}
let index = self.indices[i];
if let Ok(index) = self.brackets.binary_search_by(|x| x.0.cmp(&index)) {
let (_, ch, bracket) = self.brackets[index];
match bracket.paired_bracket_type {
BidiPairedBracketType::Open => {
if bracket_stack.depth == MAX_BRACKET_STACK {
break;
}
bracket_stack.push(i, bracket.mirroring_glyph.unwrap());
}
BidiPairedBracketType::Close => {
if let Some(open) = bracket_stack.find_and_pop(ch) {
self.bracket_pairs.push((open, i));
}
}
_ => {}
}
}
}
if self.bracket_pairs.len() > base_brackets {
let embed_dir = if level & 1 != 0 {
BidiClass::RightToLeft
} else {
BidiClass::LeftToRight
};
let bracket_pairs = &mut self.bracket_pairs[base_brackets..];
bracket_pairs.sort_unstable_by_key(|a| a.0);
for pair in bracket_pairs {
let mut pair_dir = BidiClass::OtherNeutral;
for i in pair.0 + 1..pair.1 {
let dir = match types[i] {
BidiClass::EuropeanNumber
| BidiClass::ArabicNumber
| BidiClass::ArabicLetter
| BidiClass::RightToLeft => BidiClass::RightToLeft,
BidiClass::LeftToRight => BidiClass::LeftToRight,
_ => BidiClass::OtherNeutral,
};
if dir == BidiClass::OtherNeutral {
continue;
}
pair_dir = dir;
if dir == embed_dir {
break;
}
}
if pair_dir == BidiClass::OtherNeutral {
pair.0 = self.indices[pair.0];
pair.1 = self.indices[pair.1];
continue;
}
if pair_dir != embed_dir {
pair_dir = sos;
for i in (0..pair.0).rev() {
let dir = match types[i] {
BidiClass::EuropeanNumber
| BidiClass::ArabicNumber
| BidiClass::ArabicLetter
| BidiClass::RightToLeft => BidiClass::RightToLeft,
BidiClass::LeftToRight => BidiClass::LeftToRight,
_ => BidiClass::OtherNeutral,
};
if dir != BidiClass::OtherNeutral {
pair_dir = dir;
break;
}
}
if pair_dir == embed_dir || pair_dir == BidiClass::OtherNeutral {
pair_dir = embed_dir;
}
}
types[pair.0] = pair_dir;
types[pair.1] = pair_dir;
for i in pair.0 + 1..pair.1 {
let index = self.indices[i];
if self.initial_types[index] == BidiClass::NonspacingMark {
types[i] = pair_dir;
} else {
break;
}
}
for i in pair.1 + 1..len {
let index = self.indices[i];
if self.initial_types[index] == BidiClass::NonspacingMark {
types[i] = pair_dir;
} else {
break;
}
}
pair.0 = self.indices[pair.0];
pair.1 = self.indices[pair.1];
}
}
}
// N1, N2
const N_MASK: u32 = mask(BidiClass::ParagraphSeparator)
| mask(BidiClass::SegmentSeparator)
| mask(BidiClass::WhiteSpace)
| mask(BidiClass::OtherNeutral)
| mask(BidiClass::RightToLeftIsolate)
| mask(BidiClass::LeftToRightIsolate)
| mask(BidiClass::FirstStrongIsolate)
| mask(BidiClass::PopDirectionalIsolate);
let mut i = 0;
while i < len {
let t = types[i];
if mask(t) & N_MASK != 0 {
let offset = i;
let limit = find_limit_by_mask(types, offset, N_MASK);
let mut leading;
let mut trailing;
if offset == 0 {
leading = sos;
} else {
leading = types[offset - 1];
if leading == BidiClass::ArabicNumber || leading == BidiClass::EuropeanNumber {
leading = BidiClass::RightToLeft;
}
}
if limit == len {
trailing = eos;
} else {
trailing = types[limit];
if trailing == BidiClass::ArabicNumber || trailing == BidiClass::EuropeanNumber
{
trailing = BidiClass::RightToLeft;
}
}
let resolved = if leading == trailing {
// N1
leading
} else {
// N2
if level & 1 != 0 {
BidiClass::RightToLeft
} else {
BidiClass::LeftToRight
}
};
for j in offset..limit {
types[j] = resolved;
}
i = limit - 1;
}
i += 1;
}
// Implicit levels
if level & 1 == 0 {
// I1
for i in 0..len {
let index = self.indices[i];
let t = types[i];
if t == BidiClass::RightToLeft {
self.levels[index] = level + 1;
} else if t != BidiClass::LeftToRight {
self.levels[index] = level + 2;
} else {
self.levels[index] = level;
}
}
} else {
// I2
for i in 0..len {
let index = self.indices[i];
let t = types[i];
if t != BidiClass::RightToLeft {
self.levels[index] = level + 1;
} else {
self.levels[index] = level;
}
}
}
}
}
/// Returns a default bidi type for a level.
pub(crate) fn type_from_level(level: BidiLevel) -> BidiClass {
if level & 1 == 0 {
BidiClass::LeftToRight
} else {
BidiClass::RightToLeft
}
}
/// Computes an ordering for a sequence of bidi runs based on levels.
pub(crate) fn _reorder<F>(order: &mut [usize], levels: F)
where
F: Fn(usize) -> BidiLevel,
{
let mut max_level = 0;
let mut lowest_odd_level = 255;
for (i, o) in order.iter_mut().enumerate() {
*o = i;
let level = levels(i);
if level > max_level {
max_level = level;
}
if level & 1 != 0 && level < lowest_odd_level {
lowest_odd_level = level;
}
}
let len = order.len();
for level in (lowest_odd_level..=max_level).rev() {
let mut i = 0;
while i < len {
if levels(i) >= level {
let mut end = i + 1;
while end < len && levels(end) >= level {
end += 1;
}
let mut j = i;
let mut k = end - 1;
while j < k {
order.swap(j, k);
j += 1;
k -= 1;
}
i = end;
}
i += 1;
}
}
}
/// Returns whether the character needs bidirectional resolution.
#[inline(always)]
pub(crate) fn needs_bidi_resolution(bidi_class: BidiClass) -> bool {
mask(bidi_class) & BIDI_MASK != 0
}
const OVERRIDE_MASK: u32 = mask(BidiClass::RightToLeftEmbedding)
| mask(BidiClass::LeftToRightEmbedding)
| mask(BidiClass::RightToLeftOverride)
| mask(BidiClass::LeftToRightOverride);
const ISOLATE_MASK: u32 = mask(BidiClass::RightToLeftIsolate)
| mask(BidiClass::LeftToRightIsolate)
| mask(BidiClass::FirstStrongIsolate);
const EXPLICIT_MASK: u32 = OVERRIDE_MASK | ISOLATE_MASK;
const RTL_MASK: u32 = mask(BidiClass::RightToLeftEmbedding)
| mask(BidiClass::RightToLeftOverride)
| mask(BidiClass::RightToLeftIsolate);
const REMOVED_BY_X9_MASK: u32 =
OVERRIDE_MASK | mask(BidiClass::PopDirectionalFormat) | mask(BidiClass::BoundaryNeutral);
const BIDI_MASK: u32 = EXPLICIT_MASK
| mask(BidiClass::RightToLeft)
| mask(BidiClass::ArabicLetter)
| mask(BidiClass::ArabicNumber);
const _RESET_MASK: u32 =
ISOLATE_MASK | mask(BidiClass::PopDirectionalIsolate) | mask(BidiClass::WhiteSpace);
fn is_isolate_initiator(ty: BidiClass) -> bool {
mask(ty) & ISOLATE_MASK != 0
}
pub(crate) fn is_removed_by_x9(ty: BidiClass) -> bool {
mask(ty) & REMOVED_BY_X9_MASK != 0
}
pub(crate) fn _is_reset(ty: BidiClass) -> bool {
mask(ty) & _RESET_MASK != 0
}
fn find_limit(types: &[BidiClass], offset: usize, ty: BidiClass) -> usize {
let mut len = offset;
for &t in &types[offset..] {
if t != ty {
break;
}
len += 1;
}
len
}
fn find_limit_by_mask(types: &[BidiClass], offset: usize, mask: u32) -> usize {
let mut len = offset;
for &t in &types[offset..] {
if self::mask(t) & mask == 0 {
break;
}
len += 1;
}
len
}
#[derive(Clone)]
struct Run {
level: u8,
ends_with_isolate: bool,
starts_with_pdi: bool,
sos: BidiClass,
eos: BidiClass,
start: usize,
end: usize,
in_sequence: bool,
next: Option<usize>,
}
impl Run {
fn new(level: u8, start: usize, end: usize) -> Self {
Self {
level,
ends_with_isolate: false,
starts_with_pdi: false,
sos: BidiClass::OtherNeutral,
eos: BidiClass::OtherNeutral,
start,
end,
in_sequence: false,
next: None,
}
}
}
const MAX_STACK: usize = 125;
struct Stack {
embedding_level: [u8; MAX_STACK + 1],
override_status: [BidiClass; MAX_STACK + 1],
isolate_status: [bool; MAX_STACK + 1],
depth: usize,
}
impl Stack {
fn new() -> Self {
Self {
depth: 0,
embedding_level: [0; MAX_STACK + 1],
override_status: [BidiClass::OtherNeutral; MAX_STACK + 1],
isolate_status: [false; MAX_STACK + 1],
}
}
fn push(&mut self, level: u8, override_status: BidiClass, isolate_status: bool) {
let d = self.depth;
self.embedding_level[d] = level;
self.override_status[d] = override_status;
self.isolate_status[d] = isolate_status;
self.depth += 1;
}
fn pop(&mut self) {
if self.depth > 1 {
self.depth -= 1;
}
}
fn embedding_level(&self) -> u8 {
self.embedding_level[self.depth - 1]
}
fn override_status(&self) -> BidiClass {
self.override_status[self.depth - 1]
}
fn isolate_status(&self) -> bool {
self.isolate_status[self.depth - 1]
}
}
const MAX_BRACKET_STACK: usize = 63;
struct BracketStack {
openers: [(usize, char); MAX_BRACKET_STACK],
depth: usize,
}
impl BracketStack {
fn new() -> Self {
Self {
openers: [(0, '\0'); MAX_BRACKET_STACK],
depth: 0,
}
}
fn push(&mut self, offset: usize, closer: char) {
self.openers[self.depth] = (offset, closer);
self.depth += 1;
}
fn find_and_pop(&mut self, closer: char) -> Option<usize> {
if self.depth == 0 {
return None;
}
for i in (0..self.depth).rev() {
let c = self.openers[i].1;
if c == closer
|| (c == '\u{232A}' && closer == '\u{3009}')
|| (c == '\u{3009}' && closer == '\u{232A}')
{
self.depth = i;
return Some(self.openers[i].0);
}
}
None
}
}
const fn mask(t: BidiClass) -> u32 {
1 << (t.to_icu4c_value() as u32)
}
+430
View File
@@ -0,0 +1,430 @@
// Copyright 2026 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Overrides for line-break opportunities.
use core::ops::RangeInclusive;
/// Context for a potential line break opportunity between two adjacent code points.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct LineBreakContext {
/// The code point before the `before` code point, if any.
pub before_before: Option<char>,
/// The code point before the potential break.
pub before: char,
/// The code point after the potential break.
pub after: char,
}
/// Line break opportunity override.
///
/// Called for each adjacent pair of Unicode code points in the text, in order,
/// with a [`LineBreakContext`] describing the potential break.
///
/// Returning:
/// - `Some(true)` : forces a line break opportunity between the pair
/// - `Some(false)` : suppresses any opportunity
/// - `None` : defers to the default (ICU) behavior
///
/// Mandatory breaks are unaffected (e.g. `\n`).
///
/// This is typically used to force preferential line breaking decisions when it
/// comes to ASCII punctuation like `/`, `-`, etc. For example, to prevent break
/// opportunities within "1/2".
///
/// A separate use case is to match the line breaking behavior of existing systems
/// such as web browsers. See [`CHROMIUM_LINE_BREAK_OVERRIDE`] for a ready-made
/// override function that mirrors Chromium's behavior.
pub type LineBreakOverrideFn = dyn Fn(LineBreakContext) -> Option<bool> + Send + Sync;
/// A line break override function mirroring Chromium's preferred line breaking behavior.
///
/// See: <https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/text/character_property_data_generator.cc;l=449-495>
///
/// # Differences from other browsers
///
/// ## Compared to Safari
///
/// Chromium (and this table) differs from Safari in 9 cases:
/// - A "-" followed by one of "!|$|)|/|:|;|?|]|}" is suppressed in Chromium, but broken in Safari.
///
/// ## Compared to Firefox
///
/// Firefox always defers to the default ICU behavior.
pub static CHROMIUM_LINE_BREAK_OVERRIDE: &LineBreakOverrideFn =
&(chromium_override as fn(LineBreakContext) -> Option<bool>);
fn chromium_override(cx: LineBreakContext) -> Option<bool> {
let LineBreakContext {
before_before,
before,
after,
..
} = cx;
// CSS "does not fully define where soft wrap opportunities occur".
// (https://www.w3.org/TR/css-text-3/#line-breaking)
// We find that Chrome always treats the position after a space sequence
// as a line break opportunity, despite this not matching UAX-14
// (see LB13: https://www.unicode.org/reports/tr14/#LB13; we're not currently
// aware of any others).
//
// See also https://github.com/linebender/parley/pull/485, and https://github.com/linebender/parley/issues/619.
//
// See `LazyLineBreakIterator::NextBreakablePosition`
// <https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/text/text_break_iterator.cc;l=282-303>
//
// Note that we'd need different handling in the `after == ' '` case if we ever get the equivalent of
// CSS's whitespace-collapse: break-spaces.
if before == ' ' && after != ' ' {
return Some(true);
}
// Before consulting 'before' / 'after' pair table, check for the special "-" case.
//
// Chromium doesn't allow breaking when it looks like the minus sign is for a negative
// number like "Subtract -5 from X". But, Chromium does allow breaking when the minus
// sign is part of a long URL like "AAAA-2222".
//
// See <https://github.com/chromium/chromium/blob/c6bee15e8f336c8feabf539d8bbb540c134ec20a/third_party/blink/renderer/platform/text/text_break_iterator.cc#L224-L240>
if before == '-' && after.is_ascii_digit() {
return Some(before_before.is_some_and(|c| c.is_ascii_alphanumeric()));
}
CHROMIUM_LINE_BREAK_TABLE.lookup(before, after)
}
/// A static table mirroring Chromium's preferred line breaking behavior for `before` / `after`
/// printable ASCII code points.
static CHROMIUM_LINE_BREAK_TABLE: AsciiLineBreakTable<5> =
AsciiLineBreakTableBuilder::chromium().build::<5>();
/// A line break override table for ASCII character pairs.
///
/// See [`LineBreakOverrideFn`] for more details.
///
/// All table operations are `const`, which means that derived tables don't
/// need a runtime construction step.
///
/// The table is row-deduplicated. Each `before` character maps to
/// one of `N` distinct rows, so tables can be kept tiny.
///
/// # Example
///
/// ```
/// # use parley::{CHROMIUM_LINE_BREAK_OVERRIDE, FontContext, LayoutContext};
/// # let mut font_cx = FontContext::default();
/// # let mut layout_cx: LayoutContext<[u8; 4]> = LayoutContext::new();
/// let text = "Hello there!";
/// let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true);
/// // Emulate Chromium:
/// builder.set_line_break_override(Some(CHROMIUM_LINE_BREAK_OVERRIDE));
/// let layout = builder.build(text);
/// # let _ = layout;
/// ```
#[derive(Clone)]
pub struct AsciiLineBreakTable<const N: usize> {
/// Maps each `before` character (`0..128`) to a row in `rows`.
row_of: [u8; 128],
/// The distinct rows.
rows: [Row; N],
}
impl<const N: usize> AsciiLineBreakTable<N> {
/// Look up the break override for a `(before, after)` pair.
///
/// Return semantics copied from [`LineBreakOverrideFn`].
pub const fn lookup(&self, before: char, after: char) -> Option<bool> {
let (b, a) = (before as u32, after as u32);
if b >= 128 || a >= 128 {
return None;
}
let row = &self.rows[self.row_of[b as usize] as usize];
if row.overridden & (1_u128 << a) == 0 {
return None;
}
Some(row.allow & (1_u128 << a) != 0)
}
}
/// A single deduplicated row of a [`AsciiLineBreakTable`].
#[derive(Clone, Copy)]
struct Row {
/// Bits set means the pair has an override.
overridden: u128,
/// Bits set means a break is allowed.
allow: u128,
}
/// A builder for an [`AsciiLineBreakTable`].
///
/// Construction is `const`. We create a dense `128 x 128` grid of overrides,
/// then compress it into a row-deduplicated table via [`AsciiLineBreakTableBuilder::build`].
#[derive(Clone)]
pub struct AsciiLineBreakTableBuilder {
/// Bit `after` set in row `before` means the pair has an explicit override.
overridden: [u128; 128],
/// Bit `after` set in row `before` means a break is allowed for that pair.
allow: [u128; 128],
}
impl AsciiLineBreakTableBuilder {
/// A builder that defers every pair to the default ICU behavior.
pub const fn new() -> Self {
Self {
overridden: [0; 128],
allow: [0; 128],
}
}
/// Override every pair in the cartesian product of the two inclusive ASCII
/// ranges.
///
/// # Panics
///
/// All range bounds must be printable ASCII (`<= '\u{7f}'`).
pub const fn with_pairs(
mut self,
before: RangeInclusive<char>,
after: RangeInclusive<char>,
allow_break: bool,
) -> Self {
assert!(
*before.end() as u32 <= 0x7f && *after.end() as u32 <= 0x7f,
"only printable ASCII is supported",
);
let mut before_pos = *before.start() as u32;
while before_pos <= *before.end() as u32 {
let mut after_pos = *after.start() as u32;
while after_pos <= *after.end() as u32 {
let bit = 1_u128 << after_pos;
self.overridden[before_pos as usize] |= bit;
if allow_break {
self.allow[before_pos as usize] |= bit;
} else {
self.allow[before_pos as usize] &= !bit;
}
after_pos += 1;
}
before_pos += 1;
}
self
}
/// Compress the dense grid into a row-deduplicated [`AsciiLineBreakTable`]
/// with at most `N` distinct rows.
///
/// # Panics
///
/// Panics if `N` does not exactly match the number of distinct rows required.
pub const fn build<const N: usize>(&self) -> AsciiLineBreakTable<N> {
// Row 0 is reserved as the "defer everything" row.
let mut rows = [Row {
overridden: 0,
allow: 0,
}; N];
let mut len = 1_usize;
let mut row_of = [0_u8; 128];
let mut b = 0;
while b < 128 {
let (ov, al) = (self.overridden[b], self.allow[b]);
let mut idx = 0;
let mut found = usize::MAX;
while idx < len {
if rows[idx].overridden == ov && rows[idx].allow == al {
found = idx;
break;
}
idx += 1;
}
let r = if found != usize::MAX {
found
} else {
assert!(
len < N,
"N is too small to contain table. Repeat with a larger N."
);
rows[len] = Row {
overridden: ov,
allow: al,
};
len += 1;
len - 1
};
row_of[b] = r as u8;
b += 1;
}
assert!(
len == N,
"N is larger than required. Repeat with a smaller N."
);
AsciiLineBreakTable { row_of, rows }
}
/// See [`CHROMIUM_LINE_BREAK_TABLE`] for more details.
const fn chromium() -> Self {
// The printable ASCII range `'!'..=0x7F`.
const ALL: RangeInclusive<char> = '!'..='\u{7f}';
Self::new()
.with_pairs(ALL, ALL, false)
.with_pairs(ALL, '('..='(', true)
.with_pairs(ALL, '<'..='<', true)
.with_pairs(ALL, '['..='[', true)
.with_pairs(ALL, '{'..='{', true)
.with_pairs('-'..='-', ALL, true)
.with_pairs('?'..='?', ALL, true)
.with_pairs('-'..='-', '$'..='$', false)
.with_pairs(ALL, '!'..='!', false)
.with_pairs('?'..='?', '"'..='"', false)
.with_pairs('?'..='?', '\''..='\'', false)
.with_pairs(ALL, ')'..=')', false)
.with_pairs(ALL, ','..=',', false)
.with_pairs(ALL, '.'..='.', false)
.with_pairs(ALL, '/'..='/', false)
.with_pairs('-'..='-', '0'..='9', false)
.with_pairs(ALL, ':'..=':', false)
.with_pairs(ALL, ';'..=';', false)
.with_pairs(ALL, '?'..='?', false)
.with_pairs(ALL, ']'..=']', false)
.with_pairs(ALL, '}'..='}', false)
.with_pairs('$'..='$', ALL, false)
.with_pairs('\''..='\'', ALL, false)
.with_pairs('('..='(', ALL, false)
.with_pairs('/'..='/', ALL, false)
.with_pairs('0'..='9', ALL, false)
.with_pairs('<'..='<', ALL, false)
.with_pairs('@'..='@', ALL, false)
.with_pairs('A'..='Z', ALL, false)
.with_pairs('['..='[', ALL, false)
.with_pairs('^'..='`', ALL, false)
.with_pairs('a'..='z', ALL, false)
.with_pairs('{'..='{', ALL, false)
.with_pairs('\u{7f}'..='\u{7f}', ALL, false)
}
}
impl Default for AsciiLineBreakTableBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::AsciiLineBreakTableBuilder;
use super::CHROMIUM_LINE_BREAK_TABLE;
use super::LineBreakContext;
use super::chromium_override;
fn cx(before_before: Option<char>, before: char, after: char) -> LineBreakContext {
LineBreakContext {
before_before,
before,
after,
}
}
#[test]
fn chromium_hyphen_digit_depends_on_preceding_char() {
// A break between '-' and a digit is allowed only when the character
// preceding the '-' is ASCII alphanumeric.
assert_eq!(chromium_override(cx(Some('D'), '-', '1')), Some(true));
assert_eq!(chromium_override(cx(Some('4'), '-', '5')), Some(true));
// Otherwise the '-' may be a minus sign, so the break is suppressed.
assert_eq!(chromium_override(cx(Some(' '), '-', '1')), Some(false));
assert_eq!(chromium_override(cx(Some('('), '-', '1')), Some(false));
// No preceding character (start of text) behaves like a non-alphanumeric
// context, matching Chromium's `last_last_ch == 0`.
assert_eq!(chromium_override(cx(None, '-', '1')), Some(false));
}
#[test]
fn chromium_ignores_uax_14_lb13() {
// Blink allows a break after a space run unconditionally.
// See comment in non-test code for more details.
for after in ['}', ')', ']', '!', '.', ',', '/', ':', ';', '?', 'b', '('] {
assert_eq!(
chromium_override(cx(None, ' ', after)),
Some(true),
"expected a break after the space, before {after:?}"
);
}
}
#[test]
fn chromium_hyphen_non_digit_defers_to_table() {
// '-' followed by a non-digit ignores `before_before` and uses the table.
assert_eq!(chromium_override(cx(Some('D'), '-', 'b')), Some(true));
assert_eq!(chromium_override(cx(None, '-', 'b')), Some(true));
// Non-ASCII after '-' defers to ICU.
assert_eq!(chromium_override(cx(Some('D'), '-', 'é')), None);
}
#[test]
fn chromium_suppresses_ascii_punctuation_breaks() {
let t = &CHROMIUM_LINE_BREAK_TABLE;
// No break before or after a slash.
assert_eq!(t.lookup('a', '/'), Some(false));
assert_eq!(t.lookup('/', 'b'), Some(false));
// No break around other punctuation.
assert_eq!(t.lookup('a', '.'), Some(false));
assert_eq!(t.lookup('a', ':'), Some(false));
// No break between letters.
assert_eq!(t.lookup('a', 'b'), Some(false));
}
#[test]
fn chromium_allows_some_breaks() {
let t = &CHROMIUM_LINE_BREAK_TABLE;
// Break allowed before opening punctuation, but only when the preceding
// character's row was not later suppressed.
assert_eq!(t.lookup(')', '('), Some(true));
assert_eq!(t.lookup(')', '<'), Some(true));
assert_eq!(t.lookup('a', '('), Some(false));
// Break allowed after '-' and '?' except when...
assert_eq!(t.lookup('-', 'b'), Some(true));
assert_eq!(t.lookup('?', 'b'), Some(true));
// ...break not allowed after '-' before a digit, or '?' before a quote.
assert_eq!(t.lookup('-', '5'), Some(false));
assert_eq!(t.lookup('?', '"'), Some(false));
}
#[test]
fn non_ascii_pairs_defer_to_icu() {
let t = &CHROMIUM_LINE_BREAK_TABLE;
assert_eq!(t.lookup('a', 'é'), None);
assert_eq!(t.lookup('é', 'a'), None);
// Space (0x20) is below the printable range, so it defers too.
assert_eq!(t.lookup('a', ' '), None);
}
#[test]
fn empty_table_defers_to_icu() {
let t = AsciiLineBreakTableBuilder::new().build::<1>();
assert_eq!(t.lookup('a', '/'), None);
assert_eq!(t.lookup('a', '('), None);
}
#[test]
fn dedup_matches_dense_for_chromium() {
let builder = AsciiLineBreakTableBuilder::chromium();
for b in 0..128_u32 {
for a in 0..128_u32 {
let bit = 1_u128 << a;
let dense = if builder.overridden[b as usize] & bit == 0 {
None
} else {
Some(builder.allow[b as usize] & bit != 0)
};
let (before, after) = (char::from_u32(b).unwrap(), char::from_u32(a).unwrap());
assert_eq!(
CHROMIUM_LINE_BREAK_TABLE.lookup(before, after),
dense,
"mismatch at (before={b:#04x}, after={a:#04x})",
);
}
}
}
}
+366
View File
@@ -0,0 +1,366 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Context for layout.
use super::FontContext;
use super::context::LayoutContext;
use super::style::{Brush, StyleProperty, TextStyle, WhiteSpaceCollapse};
use super::layout::Layout;
use alloc::string::String;
use core::ops::{Bound, Range, RangeBounds};
use crate::InlineBoxKind;
use crate::break_overrides::LineBreakOverrideFn;
use crate::inline_box::InlineBox;
use crate::resolve::{ResolvedStyle, StyleRun, tree::ItemKind};
/// Builder for constructing a text layout with ranged attributes.
#[must_use]
pub struct RangedBuilder<'a, B: Brush> {
pub(crate) scale: f32,
pub(crate) quantize: bool,
pub(crate) lcx: &'a mut LayoutContext<B>,
pub(crate) fcx: &'a mut FontContext,
pub(crate) line_break_override: Option<&'a LineBreakOverrideFn>,
}
impl<'b, B: Brush> RangedBuilder<'b, B> {
pub fn push_default<'a>(&mut self, property: impl Into<StyleProperty<'a, B>>) {
let resolved = self
.lcx
.rcx
.resolve_property(self.fcx, &property.into(), self.scale);
self.lcx.ranged_style_builder.push_default(resolved);
}
pub fn push<'a>(
&mut self,
property: impl Into<StyleProperty<'a, B>>,
range: impl RangeBounds<usize>,
) {
let resolved = self
.lcx
.rcx
.resolve_property(self.fcx, &property.into(), self.scale);
self.lcx.ranged_style_builder.push(resolved, range);
}
pub fn push_inline_box(&mut self, inline_box: InlineBox) {
self.lcx.inline_boxes.push(inline_box);
}
/// Set the callback which will be called as a first provider of line breaking decisions.
///
/// See [`LineBreakOverrideFn`] for more details.
pub fn set_line_break_override(&mut self, overrides: Option<&'b LineBreakOverrideFn>) {
self.line_break_override = overrides;
}
pub fn build_into(self, layout: &mut Layout<B>, text: impl AsRef<str>) {
// Apply RangedStyleBuilder styles directly to style-table/style-run state.
self.lcx
.ranged_style_builder
.finish(&mut self.lcx.style_table, &mut self.lcx.style_runs);
// Call generic layout builder method
build_into_layout(
layout,
self.scale,
self.quantize,
text.as_ref(),
self.lcx,
self.fcx,
self.line_break_override,
);
}
pub fn build(self, text: impl AsRef<str>) -> Layout<B> {
let mut layout = Layout::default();
self.build_into(&mut layout, text);
layout
}
}
/// Builder for constructing a text layout from a style table and
/// indexed style runs.
#[must_use]
pub struct StyleRunBuilder<'a, B: Brush> {
pub(crate) scale: f32,
pub(crate) quantize: bool,
pub(crate) len: usize,
pub(crate) lcx: &'a mut LayoutContext<B>,
pub(crate) fcx: &'a mut FontContext,
pub(crate) cursor: usize,
pub(crate) line_break_override: Option<&'a LineBreakOverrideFn>,
}
impl<'b, B: Brush> StyleRunBuilder<'b, B> {
/// Reserves additional capacity for styles and runs.
///
/// This is an optional optimization for callers that know counts
/// up front; call it before pushing styles and runs to reduce
/// reallocations.
pub fn reserve(&mut self, additional_styles: usize, additional_runs: usize) {
self.lcx.style_table.reserve(additional_styles);
self.lcx.style_runs.reserve(additional_runs);
}
/// Adds a fully-specified style to the shared style table and
/// returns its index.
pub fn push_style<'family, 'settings>(
&mut self,
style: TextStyle<'family, 'settings, B>,
) -> u16 {
let resolved = self
.lcx
.rcx
.resolve_entire_style_set(self.fcx, &style, self.scale);
let style_index = self.lcx.style_table.len();
assert!(style_index <= u16::MAX as usize, "too many styles");
self.lcx.style_table.push(resolved);
style_index as u16
}
/// Adds a style run referencing an entry from the style table.
///
/// Runs must be contiguous and non-overlapping, and must cover
/// `0..text.len()` once all runs have been added.
pub fn push_style_run(&mut self, style_index: u16, range: impl RangeBounds<usize>) {
let range = resolve_range(range, self.len);
assert!(
range.start == self.cursor,
"StyleRunBuilder expects contiguous non-overlapping runs"
);
assert!(
range.start <= range.end,
"StyleRunBuilder expects ordered ranges"
);
assert!(
(style_index as usize) < self.lcx.style_table.len(),
"StyleRunBuilder expects style indices that were previously added via push_style"
);
self.lcx.style_runs.push(StyleRun {
style_index,
range: range.clone(),
});
self.cursor = range.end;
}
pub fn push_inline_box(&mut self, inline_box: InlineBox) {
self.lcx.inline_boxes.push(inline_box);
}
/// Set the callback which will be called as a first provider of line breaking decisions.
///
/// See [`LineBreakOverrideFn`] for more details.
pub fn set_line_break_override(&mut self, overrides: Option<&'b LineBreakOverrideFn>) {
self.line_break_override = overrides;
}
pub fn build_into(self, layout: &mut Layout<B>, text: impl AsRef<str>) {
assert!(
self.cursor == self.len,
"StyleRunBuilder requires runs that cover the full text"
);
build_into_layout(
layout,
self.scale,
self.quantize,
text.as_ref(),
self.lcx,
self.fcx,
self.line_break_override,
);
}
pub fn build(self, text: impl AsRef<str>) -> Layout<B> {
let mut layout = Layout::default();
self.build_into(&mut layout, text);
layout
}
}
/// Builder for constructing a text layout with a tree of attributes.
#[must_use]
pub struct TreeBuilder<'a, B: Brush> {
pub(crate) scale: f32,
pub(crate) quantize: bool,
pub(crate) lcx: &'a mut LayoutContext<B>,
pub(crate) fcx: &'a mut FontContext,
pub(crate) line_break_override: Option<&'a LineBreakOverrideFn>,
}
impl<'b, B: Brush> TreeBuilder<'b, B> {
pub fn push_style_span(&mut self, style: TextStyle<'_, '_, B>) {
let resolved = self
.lcx
.rcx
.resolve_entire_style_set(self.fcx, &style, self.scale);
self.lcx.tree_style_builder.push_style_span(resolved);
}
pub fn push_style_modification_span<'s, 'iter>(
&mut self,
properties: impl IntoIterator<Item = &'iter StyleProperty<'s, B>>,
) where
's: 'iter,
B: 'iter,
{
self.lcx.tree_style_builder.push_style_modification_span(
properties
.into_iter()
.map(|p| self.lcx.rcx.resolve_property(self.fcx, p, self.scale)),
);
}
pub fn pop_style_span(&mut self) {
self.lcx.tree_style_builder.pop_style_span();
}
pub fn push_text(&mut self, text: &str) {
self.lcx.tree_style_builder.push_text(text);
}
pub fn push_inline_box(&mut self, mut inline_box: InlineBox) {
if inline_box.kind == InlineBoxKind::InFlow {
self.lcx.tree_style_builder.push_uncommitted_text(false);
self.lcx.tree_style_builder.set_is_span_first(false);
self.lcx
.tree_style_builder
.set_last_item_kind(ItemKind::InlineBox);
}
// TODO: arrange type better here to factor out the index
inline_box.index = self.lcx.tree_style_builder.current_text_len();
self.lcx.inline_boxes.push(inline_box);
}
pub fn set_white_space_mode(&mut self, white_space_collapse: WhiteSpaceCollapse) {
self.lcx
.tree_style_builder
.set_white_space_mode(white_space_collapse);
}
/// Set the callback which will be called as a first provider of line breaking decisions.
///
/// See [`LineBreakOverrideFn`] for more details.
pub fn set_line_break_override(&mut self, overrides: Option<&'b LineBreakOverrideFn>) {
self.line_break_override = overrides;
}
#[inline]
pub fn build_into(self, layout: &mut Layout<B>) -> String {
// Apply TreeStyleBuilder styles to LayoutContext.
let text = self
.lcx
.tree_style_builder
.finish(&mut self.lcx.style_table, &mut self.lcx.style_runs);
// Call generic layout builder method
build_into_layout(
layout,
self.scale,
self.quantize,
&text,
self.lcx,
self.fcx,
self.line_break_override,
);
text
}
#[inline]
pub fn build(self) -> (Layout<B>, String) {
let mut layout = Layout::default();
let text = self.build_into(&mut layout);
(layout, text)
}
}
fn build_into_layout<B: Brush>(
layout: &mut Layout<B>,
scale: f32,
quantize: bool,
text: &str,
lcx: &mut LayoutContext<B>,
fcx: &mut FontContext,
line_break_override: Option<&LineBreakOverrideFn>,
) {
if text.is_empty() && lcx.style_runs.is_empty() {
lcx.style_table.push(ResolvedStyle::default());
lcx.style_runs.push(StyleRun {
style_index: 0,
range: 0..0,
});
}
assert!(
!lcx.style_runs.is_empty(),
"at least one style run is required"
);
crate::analysis::analyze_text(lcx, text, line_break_override);
layout.data.clear();
layout.data.scale = scale;
layout.data.quantize = quantize;
layout.data.base_level = lcx.bidi.base_level();
layout.data.text_len = text.len();
let mut char_index = 0;
for style_run in &lcx.style_runs {
for _ in text[style_run.range.clone()].chars() {
lcx.info[char_index].1 = style_run.style_index;
char_index += 1;
}
}
// Copy the visual styles into the layout
layout
.data
.styles
.extend(lcx.style_table.iter().map(|s| s.as_layout_style()));
// Sort the inline boxes as subsequent code assumes that they are in text index order.
// Note: It's important that this is a stable sort to allow users to control the order of contiguous inline boxes
lcx.inline_boxes.sort_by_key(|b| b.index);
{
let query = fcx.collection.query(&mut fcx.source_cache);
super::shape::shape_text(
&lcx.rcx,
query,
&lcx.style_table,
&lcx.inline_boxes,
&lcx.info,
lcx.bidi.levels(),
&mut lcx.scx,
text,
layout,
&lcx.analysis_data_sources,
);
}
// Move inline boxes into the layout
layout.data.inline_boxes.clear();
core::mem::swap(&mut layout.data.inline_boxes, &mut lcx.inline_boxes);
layout.data.finish();
}
fn resolve_range(range: impl RangeBounds<usize>, len: usize) -> Range<usize> {
let start = match range.start_bound() {
Bound::Unbounded => 0,
Bound::Included(n) => *n,
Bound::Excluded(n) => *n + 1,
};
let end = match range.end_bound() {
Bound::Unbounded => len,
Bound::Included(n) => *n + 1,
Bound::Excluded(n) => *n,
};
start.min(len)..end.min(len)
}
+204
View File
@@ -0,0 +1,204 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Context for layout.
use alloc::{vec, vec::Vec};
use super::FontContext;
use super::builder::{RangedBuilder, StyleRunBuilder};
use super::resolve::tree::TreeStyleBuilder;
use super::resolve::{RangedStyleBuilder, ResolveContext, ResolvedStyle, StyleRun};
use super::style::{Brush, TextStyle};
use crate::analysis::{AnalysisDataSources, CharInfo};
use crate::bidi::BidiResolver;
use crate::builder::TreeBuilder;
use crate::inline_box::InlineBox;
use crate::shape::ShapeContext;
/// Shared scratch space used when constructing text layouts.
///
/// This type is designed to be a global resource with only one per-application (or per-thread).
pub struct LayoutContext<B: Brush = [u8; 4]> {
pub(crate) rcx: ResolveContext,
pub(crate) style_table: Vec<ResolvedStyle<B>>,
pub(crate) style_runs: Vec<StyleRun>,
pub(crate) inline_boxes: Vec<InlineBox>,
pub(crate) bidi: BidiResolver,
// Reusable style builders (to amortise allocations)
pub(crate) ranged_style_builder: RangedStyleBuilder<B>,
pub(crate) tree_style_builder: TreeStyleBuilder<B>,
// u16: style index for character
pub(crate) info: Vec<(CharInfo, u16)>,
pub(crate) scx: ShapeContext,
// Unicode analysis data sources (provided by icu)
pub(crate) analysis_data_sources: AnalysisDataSources,
}
impl<B: Brush> LayoutContext<B> {
pub fn new() -> Self {
Self {
rcx: ResolveContext::default(),
style_table: vec![],
style_runs: vec![],
inline_boxes: vec![],
bidi: BidiResolver::new(),
ranged_style_builder: RangedStyleBuilder::default(),
tree_style_builder: TreeStyleBuilder::default(),
info: vec![],
analysis_data_sources: AnalysisDataSources::new(),
scx: ShapeContext::default(),
}
}
fn resolve_style_set(
&mut self,
font_ctx: &mut FontContext,
scale: f32,
raw_style: &TextStyle<'_, '_, B>,
) -> ResolvedStyle<B> {
self.rcx
.resolve_entire_style_set(font_ctx, raw_style, scale)
}
/// Create a ranged style layout builder.
///
/// Set `quantize` as `true` to have the layout coordinates aligned to pixel boundaries.
/// That is the easiest way to avoid blurry text and to receive ready-to-paint layout metrics.
///
/// For advanced rendering use cases you can set `quantize` as `false` and receive
/// fractional coordinates. This ensures the most accurate results if you want to perform
/// some post-processing on the coordinates before painting. To avoid blurry text you will
/// still need to quantize the coordinates just before painting.
///
/// Your should round at least the following:
/// * Glyph run baseline
/// * Inline box baseline
/// - `box.y = (box.y + box.height).round() - box.height`
/// * Selection geometry's `y0` & `y1`
/// * Cursor geometry's `y0` & `y1`
///
/// Keep in mind that for the simple `f32::round` to be effective,
/// you need to first ensure the coordinates are in physical pixel space.
pub fn ranged_builder<'a>(
&'a mut self,
fcx: &'a mut FontContext,
text: &'a str,
scale: f32,
quantize: bool,
) -> RangedBuilder<'a, B> {
self.begin();
let resolved_root_style = self.resolve_style_set(fcx, scale, &TextStyle::default());
self.ranged_style_builder
.begin(resolved_root_style, text.len());
fcx.source_cache.prune(128, false);
RangedBuilder {
scale,
quantize,
lcx: self,
fcx,
line_break_override: None,
}
}
/// Create a builder for constructing a layout from indexed style runs.
///
/// Unlike [`Self::ranged_builder`], this builder expects callers to provide:
/// - a style table of fully specified [`TextStyle`] values (via [`StyleRunBuilder::push_style`])
/// - a complete sequence of **contiguous**, **non-overlapping** spans that cover
/// `0..text.len()` and reference style indices (via [`StyleRunBuilder::push_style_run`])
///
/// Parley then skips its internal range-splitting logic.
pub fn style_run_builder<'a>(
&'a mut self,
fcx: &'a mut FontContext,
text: &'a str,
scale: f32,
quantize: bool,
) -> StyleRunBuilder<'a, B> {
self.begin();
fcx.source_cache.prune(128, false);
StyleRunBuilder {
scale,
quantize,
len: text.len(),
lcx: self,
fcx,
cursor: 0,
line_break_override: None,
}
}
/// Create a tree style layout builder.
///
/// Set `quantize` as `true` to have the layout coordinates aligned to pixel boundaries.
/// That is the easiest way to avoid blurry text and to receive ready-to-paint layout metrics.
///
/// For advanced rendering use cases you can set `quantize` as `false` and receive
/// fractional coordinates. This ensures the most accurate results if you want to perform
/// some post-processing on the coordinates before painting. To avoid blurry text you will
/// still need to quantize the coordinates just before painting.
///
/// Your should round at least the following:
/// * Glyph run baseline
/// * Inline box baseline
/// - `box.y = (box.y + box.height).round() - box.height`
/// * Selection geometry's `y0` & `y1`
/// * Cursor geometry's `y0` & `y1`
///
/// Keep in mind that for the simple `f32::round` to be effective,
/// you need to first ensure the coordinates are in physical pixel space.
pub fn tree_builder<'a>(
&'a mut self,
fcx: &'a mut FontContext,
scale: f32,
quantize: bool,
root_style: &TextStyle<'_, '_, B>,
) -> TreeBuilder<'a, B> {
self.begin();
let resolved_root_style = self.resolve_style_set(fcx, scale, root_style);
self.tree_style_builder.begin(resolved_root_style);
fcx.source_cache.prune(128, false);
TreeBuilder {
scale,
quantize,
lcx: self,
fcx,
line_break_override: None,
}
}
fn begin(&mut self) {
self.rcx.clear();
self.style_table.clear();
self.style_runs.clear();
self.inline_boxes.clear();
self.info.clear();
self.bidi.clear();
}
}
impl<B: Brush> Default for LayoutContext<B> {
fn default() -> Self {
Self::new()
}
}
impl<B: Brush> Clone for LayoutContext<B> {
fn clone(&self) -> Self {
// None of the internal state is visible so just return a new instance.
Self::new()
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2024 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::analysis::AnalysisDataSources;
use icu_properties::props::Script;
pub(crate) fn script_to_fontique(
script: Script,
analysis_data_sources: &AnalysisDataSources,
) -> fontique::Script {
analysis_data_sources
.script_short_name()
.get(script)
.unwrap_or("Zzzz")
.parse()
.unwrap_or(fontique::Script::UNKNOWN)
}
pub(crate) fn script_to_harfrust(script: fontique::Script) -> harfrust::Script {
harfrust::Script::from_iso15924_tag(harfrust::Tag::new(&script.to_bytes()))
.unwrap_or(harfrust::script::UNKNOWN)
}
+466
View File
@@ -0,0 +1,466 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::BoundingBox;
#[cfg(feature = "accesskit")]
use crate::analysis::cluster::Whitespace;
use crate::layout::{Affinity, BreakReason, Cluster, ClusterSide, Layout, Line};
#[cfg(feature = "accesskit")]
use crate::layout::{ClusterPath, LayoutAccessibility};
use crate::style::Brush;
#[cfg(feature = "accesskit")]
use accesskit::TextPosition;
/// Defines a position with a text layout.
#[derive(Copy, Clone, PartialEq, Eq, Default, Debug)]
pub struct Cursor {
pub(crate) index: usize,
pub(crate) affinity: Affinity,
}
impl Cursor {
/// Creates a new cursor from the given byte index and affinity.
pub fn from_byte_index<B: Brush>(layout: &Layout<B>, index: usize, affinity: Affinity) -> Self {
if let Some(cluster) = Cluster::from_byte_index(layout, index) {
let index = cluster.text_range().start;
Self {
index,
affinity: if index != 0 {
affinity
} else {
// There is no Upstream cluster of the 0 position so we force Downstream affinity.
Affinity::Downstream
},
}
} else {
Self {
index: layout.data.text_len,
affinity: Affinity::Upstream,
}
}
}
/// Creates a new cursor from the given coordinates.
pub fn from_point<B: Brush>(layout: &Layout<B>, x: f32, y: f32) -> Self {
let (index, affinity) = if let Some((cluster, side)) = Cluster::from_point(layout, x, y) {
let is_leading = side == ClusterSide::Left;
if cluster.is_rtl() {
if is_leading {
(cluster.text_range().end, Affinity::Upstream)
} else {
(cluster.text_range().start, Affinity::Downstream)
}
} else {
// We never want to position the cursor _after_ a hard
// line since that cursor appears visually at the start
// of the next line
if is_leading || cluster.is_line_break() == Some(BreakReason::Explicit) {
(cluster.text_range().start, Affinity::Downstream)
} else {
(cluster.text_range().end, Affinity::Upstream)
}
}
} else {
(layout.data.text_len, Affinity::Downstream)
};
Self { index, affinity }
}
#[cfg(feature = "accesskit")]
pub fn from_access_position<B: Brush>(
pos: &TextPosition,
layout: &Layout<B>,
layout_access: &LayoutAccessibility,
) -> Option<Self> {
let span_path = layout_access.span_paths_by_access_id.get(&pos.node)?;
let run = span_path.run(layout)?;
let index = run
.get(span_path.logical_index() + pos.character_index)
.map(|cluster| cluster.text_range().start)
.unwrap_or(layout.data.text_len);
Some(Self::from_byte_index(layout, index, Affinity::Downstream))
}
pub(crate) fn from_cluster<B: Brush>(
layout: &Layout<B>,
cluster: Cluster<'_, B>,
moving_right: bool,
) -> Self {
Self::from_byte_index(
layout,
cluster.text_range().start,
affinity_for_dir(cluster.is_rtl(), moving_right),
)
}
/// Returns the logical text index of the cursor.
pub fn index(&self) -> usize {
self.index
}
/// Returns the affinity of the cursor.
///
/// This defines the direction from which the cursor entered its current
/// position and affects the visual location of the rendered cursor.
pub fn affinity(&self) -> Affinity {
self.affinity
}
/// Returns a new cursor that is guaranteed to be within the bounds of the
/// given layout.
#[must_use]
pub fn refresh<B: Brush>(&self, layout: &Layout<B>) -> Self {
Self::from_byte_index(layout, self.index, self.affinity)
}
/// Returns a new cursor that is positioned at the previous cluster boundary
/// in visual order.
#[must_use]
pub fn previous_visual<B: Brush>(&self, layout: &Layout<B>) -> Self {
let [left, right] = self.visual_clusters(layout);
if let (Some(left), Some(right)) = (&left, &right) {
if left.is_soft_line_break() {
if left.is_rtl() && self.affinity == Affinity::Upstream {
let index = if right.is_rtl() {
left.text_range().start
} else {
left.text_range().end
};
return Self::from_byte_index(layout, index, Affinity::Downstream);
} else if !left.is_rtl() && self.affinity == Affinity::Downstream {
let index = if right.is_rtl() {
right.text_range().end
} else {
right.text_range().start
};
return Self::from_byte_index(layout, index, Affinity::Upstream);
}
}
}
if let Some(left) = left {
let index = if left.is_rtl() {
left.text_range().end
} else {
left.text_range().start
};
return Self::from_byte_index(layout, index, affinity_for_dir(left.is_rtl(), false));
}
*self
}
/// Returns a new cursor that is positioned at the next cluster boundary
/// in visual order.
#[must_use]
pub fn next_visual<B: Brush>(&self, layout: &Layout<B>) -> Self {
let [left, right] = self.visual_clusters(layout);
if let (Some(left), Some(right)) = (&left, &right) {
if left.is_soft_line_break() {
if left.is_rtl() && self.affinity == Affinity::Downstream {
let index = if right.is_rtl() {
right.text_range().end
} else {
right.text_range().start
};
return Self::from_byte_index(layout, index, Affinity::Upstream);
} else if !left.is_rtl() && self.affinity == Affinity::Upstream {
let index = if right.is_rtl() {
right.text_range().end
} else {
right.text_range().start
};
return Self::from_byte_index(layout, index, Affinity::Downstream);
}
}
let index = if right.is_rtl() {
right.text_range().start
} else {
right.text_range().end
};
return Self::from_byte_index(layout, index, affinity_for_dir(right.is_rtl(), true));
}
if let Some(right) = right {
let index = if right.is_rtl() {
right.text_range().start
} else {
right.text_range().end
};
return Self::from_byte_index(layout, index, affinity_for_dir(right.is_rtl(), true));
}
*self
}
/// Returns a new cursor that is positioned at the next word boundary
/// in visual order.
#[must_use]
pub fn next_visual_word<B: Brush>(&self, layout: &Layout<B>) -> Self {
let mut cur = *self;
loop {
let next = cur.next_visual(layout);
if next == cur {
break;
}
cur = next;
let [Some(left), Some(right)] = cur.visual_clusters(layout) else {
break;
};
if left.is_rtl() {
if left.is_word_boundary() && !left.is_space_or_nbsp() {
break;
}
} else if right.is_word_boundary() && !left.is_space_or_nbsp() {
break;
}
}
cur
}
/// Returns a new cursor that is positioned at the previous word boundary
/// in visual order.
#[must_use]
pub fn previous_visual_word<B: Brush>(&self, layout: &Layout<B>) -> Self {
let mut cur = *self;
loop {
let next = cur.previous_visual(layout);
if next == cur {
break;
}
cur = next;
let [Some(left), Some(right)] = cur.visual_clusters(layout) else {
break;
};
if left.is_rtl() {
if left.is_word_boundary()
&& (left.is_space_or_nbsp()
|| (right.is_word_boundary() && !right.is_space_or_nbsp()))
{
break;
}
} else if right.is_word_boundary() && !right.is_space_or_nbsp() {
break;
}
}
cur
}
/// Returns a new cursor that is positioned at the next word boundary
/// in logical order.
#[must_use]
pub fn next_logical_word<B: Brush>(&self, layout: &Layout<B>) -> Self {
let [left, right] = self.logical_clusters(layout);
if let Some(cluster) = right.or(left) {
let start = cluster.clone();
let cluster = cluster.next_logical_word().unwrap_or(cluster);
if cluster.path == start.path {
return Self::from_byte_index(layout, usize::MAX, Affinity::Downstream);
}
return Self::from_cluster(layout, cluster, true);
}
*self
}
/// Returns a new cursor that is positioned at the previous word boundary
/// in logical order.
#[must_use]
pub fn previous_logical_word<B: Brush>(&self, layout: &Layout<B>) -> Self {
let [left, right] = self.logical_clusters(layout);
if let Some(cluster) = left.or(right) {
let cluster = cluster.previous_logical_word().unwrap_or(cluster);
return Self::from_cluster(layout, cluster, true);
}
*self
}
/// Returns a rectangle that represents the visual geometry of the cursor
/// in layout space.
///
/// The `width` parameter defines the width of the resulting rectangle.
pub fn geometry<B: Brush>(&self, layout: &Layout<B>, width: f32) -> BoundingBox {
match self.visual_clusters(layout) {
[Some(left), Some(right)] => {
if left.is_end_of_line() {
if left.is_soft_line_break() {
let (cluster, at_end) = if left.is_rtl()
&& self.affinity == Affinity::Downstream
|| !left.is_rtl() && self.affinity == Affinity::Upstream
{
(left, true)
} else {
(right, false)
};
cursor_rect(&cluster, at_end, width)
} else {
cursor_rect(&right, false, width)
}
} else {
cursor_rect(&left, true, width)
}
}
[Some(left), None] if left.is_hard_line_break() => last_line_cursor_rect(layout, width),
[Some(left), _] => cursor_rect(&left, true, width),
[_, Some(right)] => cursor_rect(&right, false, width),
_ => last_line_cursor_rect(layout, width),
}
}
/// Returns the pair of clusters that logically bound the cursor
/// position.
///
/// The order in the array is upstream followed by downstream.
pub fn logical_clusters<'a, B: Brush>(
&self,
layout: &'a Layout<B>,
) -> [Option<Cluster<'a, B>>; 2] {
let upstream = self
.index
.checked_sub(1)
.and_then(|index| Cluster::from_byte_index(layout, index));
let downstream = Cluster::from_byte_index(layout, self.index);
[upstream, downstream]
}
/// Returns the pair of clusters that visually bound the cursor
/// position.
///
/// The order in the array is left followed by right.
pub fn visual_clusters<'a, B: Brush>(
&self,
layout: &'a Layout<B>,
) -> [Option<Cluster<'a, B>>; 2] {
if self.affinity == Affinity::Upstream {
if let Some(cluster) = self.upstream_cluster(layout) {
if cluster.is_rtl() {
[cluster.previous_visual(), Some(cluster)]
} else {
[Some(cluster.clone()), cluster.next_visual()]
}
} else if let Some(cluster) = self.downstream_cluster(layout) {
if cluster.is_rtl() {
[None, Some(cluster)]
} else {
[Some(cluster), None]
}
} else {
[None, None]
}
} else if let Some(cluster) = self.downstream_cluster(layout) {
if cluster.is_rtl() {
[Some(cluster.clone()), cluster.next_visual()]
} else {
[cluster.previous_visual(), Some(cluster)]
}
} else if let Some(cluster) = self.upstream_cluster(layout) {
if cluster.is_rtl() {
[None, Some(cluster)]
} else {
[Some(cluster), None]
}
} else {
[None, None]
}
}
pub(crate) fn line<B: Brush>(self, layout: &Layout<B>) -> Option<(usize, Line<'_, B>)> {
let geometry = self.geometry(layout, 0.0);
layout.line_for_offset(geometry.y0 as f32)
}
pub(crate) fn upstream_cluster<B: Brush>(self, layout: &Layout<B>) -> Option<Cluster<'_, B>> {
self.index
.checked_sub(1)
.and_then(|index| Cluster::from_byte_index(layout, index))
}
pub(crate) fn downstream_cluster<B: Brush>(self, layout: &Layout<B>) -> Option<Cluster<'_, B>> {
Cluster::from_byte_index(layout, self.index)
}
#[cfg(feature = "accesskit")]
pub fn to_access_position<B: Brush>(
&self,
layout: &Layout<B>,
layout_access: &LayoutAccessibility,
) -> Option<TextPosition> {
if layout.data.text_len == 0 {
// If the text is empty, just return the first node with a
// character index of 0.
return Some(TextPosition {
node: *layout_access
.access_ids_by_span_path
.get(&ClusterPath::new(0, 0, 0))?,
character_index: 0,
});
}
// Prefer the downstream cluster except at the end of the text
// where we'll choose the upstream cluster and add 1 to the
// character index.
let (offset, path) = self
.downstream_cluster(layout)
.map(|cluster| (0, cluster.path))
.or_else(|| {
self.upstream_cluster(layout)
.map(|cluster| (1, cluster.path))
})?;
// If we're at the end of the layout and the layout ends with a newline
// then make sure we use the "phantom" run at the end so that
// AccessKit has correct visual geometry for the cursor.
let (span_path, character_index) = if self.index == layout.data.text_len
&& layout
.data
.clusters
.last()
.map(|cluster| cluster.info.whitespace() == Whitespace::Newline)
.unwrap_or_default()
{
(ClusterPath::new(path.line_index + 1, 0, 0), 0)
} else {
let span_path = layout_access.span_paths_by_cluster_path.get(&path).unwrap();
(
*span_path,
path.logical_index() - span_path.logical_index() + offset,
)
};
let id = layout_access.access_ids_by_span_path.get(&span_path)?;
Some(TextPosition {
node: *id,
character_index,
})
}
}
// ---
fn affinity_for_dir(is_rtl: bool, moving_right: bool) -> Affinity {
match (is_rtl, moving_right) {
(true, true) | (false, false) => Affinity::Downstream,
_ => Affinity::Upstream,
}
}
fn cursor_rect<B: Brush>(cluster: &Cluster<'_, B>, at_end: bool, size: f32) -> BoundingBox {
let mut line_x = cluster.visual_offset().unwrap_or_default();
if at_end {
line_x += cluster.advance();
}
let line = cluster.line();
let metrics = line.metrics();
BoundingBox::new(
line_x as f64,
metrics.block_min_coord as f64,
(line_x + size) as f64,
metrics.block_max_coord as f64,
)
}
fn last_line_cursor_rect<B: Brush>(layout: &Layout<B>, size: f32) -> BoundingBox {
if let Some(line) = layout.get(layout.len().saturating_sub(1)) {
let metrics = line.metrics();
BoundingBox::new(
metrics.offset as f64,
metrics.block_min_coord as f64,
(metrics.offset + size) as f64,
metrics.block_max_coord as f64,
)
} else {
BoundingBox::default()
}
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
mod cursor;
mod editor;
mod selection;
pub use self::cursor::*;
pub use self::editor::*;
pub use self::selection::*;
+672
View File
@@ -0,0 +1,672 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::BoundingBox;
use crate::editing::Cursor;
#[cfg(feature = "accesskit")]
use crate::layout::LayoutAccessibility;
use crate::layout::{Affinity, BreakReason, Cluster, Layout, LineItem};
use crate::style::Brush;
use alloc::vec::Vec;
use core::ops::Range;
/// Defines a range within a text layout.
#[derive(Copy, Clone, Default, Debug)]
pub struct Selection {
anchor: Cursor,
focus: Cursor,
anchor_base: AnchorBase,
h_pos: Option<f32>,
}
#[derive(Copy, Clone, Default, Debug)]
enum AnchorBase {
#[default]
Cluster,
Word(Cursor, Cursor),
Line(Cursor, Cursor),
}
impl Selection {
/// Creates a new selection from the given anchor and focus cursors.
pub fn new(anchor: Cursor, focus: Cursor) -> Self {
Self {
anchor,
focus,
anchor_base: AnchorBase::default(),
h_pos: None,
}
}
/// Creates a new collapsed selection from the given byte index and
/// affinity.
pub fn from_byte_index<B: Brush>(layout: &Layout<B>, index: usize, affinity: Affinity) -> Self {
Cursor::from_byte_index(layout, index, affinity).into()
}
/// Creates a new collapsed selection from the given point.
pub fn from_point<B: Brush>(layout: &Layout<B>, x: f32, y: f32) -> Self {
Cursor::from_point(layout, x, y).into()
}
/// Creates a new selection bounding the word at the given coordinates.
pub fn word_from_point<B: Brush>(layout: &Layout<B>, x: f32, y: f32) -> Self {
if let Some((mut cluster, _)) = Cluster::from_point(layout, x, y) {
if !cluster.is_word_boundary() {
if let Some(prev) = cluster.previous_logical_word() {
cluster = prev;
}
}
let anchor = Cursor::from_cluster(layout, cluster.clone(), !cluster.is_rtl());
let focus = anchor.next_logical_word(layout);
Self {
anchor,
focus,
anchor_base: AnchorBase::Word(anchor, focus),
h_pos: None,
}
} else {
Cursor::from_byte_index(layout, layout.data.text_len, Affinity::Upstream).into()
}
}
/// Creates a new selection bounding the line at the given coordinates.
pub fn line_from_point<B: Brush>(layout: &Layout<B>, x: f32, y: f32) -> Self {
let Self { anchor, focus, .. } = Self::from_point(layout, x, y)
.line_start(layout, false)
.line_end(layout, true);
Self {
anchor,
focus,
anchor_base: AnchorBase::Line(anchor, focus),
h_pos: None,
}
}
/// Creates a new selection bounding the "logical" line at the given coordinates.
///
/// That is, the line as defined by line break characters, rather than due to soft-wrapping.
pub fn hard_line_from_point<B: Brush>(layout: &Layout<B>, x: f32, y: f32) -> Self {
let Self { anchor, focus, .. } = Self::from_point(layout, x, y)
.hard_line_start(layout, false)
.hard_line_end(layout, true);
Self {
anchor,
focus,
anchor_base: AnchorBase::Line(anchor, focus),
h_pos: None,
}
}
#[cfg(feature = "accesskit")]
pub fn from_access_selection<B: Brush>(
selection: &accesskit::TextSelection,
layout: &Layout<B>,
layout_access: &LayoutAccessibility,
) -> Option<Self> {
let anchor = Cursor::from_access_position(&selection.anchor, layout, layout_access)?;
let focus = Cursor::from_access_position(&selection.focus, layout, layout_access)?;
Some(Self::new(anchor, focus))
}
/// Returns `true` if the anchor and focus of the selection are the same.
///
/// This means that the selection represents a single position rather than
/// a range.
pub fn is_collapsed(&self) -> bool {
self.anchor.index == self.focus.index
}
/// Returns the anchor of the selection.
///
/// In a non-collapsed selection, this indicates where the selection was
/// initiated.
pub fn anchor(&self) -> Cursor {
self.anchor
}
/// Returns the focus of the selection.
///
/// In a non-collapsed selection, this indicates the current position.
pub fn focus(&self) -> Cursor {
self.focus
}
/// Returns a new collapsed selection at the position of the current
/// focus.
#[must_use]
pub fn collapse(&self) -> Self {
self.focus.into()
}
/// Returns a new selection that is guaranteed to be within the bounds of
/// the given layout.
#[must_use]
pub fn refresh<B: Brush>(&self, layout: &Layout<B>) -> Self {
let anchor = self.anchor.refresh(layout);
let focus = self.focus.refresh(layout);
let anchor_base = match self.anchor_base {
AnchorBase::Cluster => AnchorBase::Cluster,
AnchorBase::Word(start, end) => {
AnchorBase::Word(start.refresh(layout), end.refresh(layout))
}
AnchorBase::Line(start, end) => {
AnchorBase::Line(start.refresh(layout), end.refresh(layout))
}
};
let h_pos = self.h_pos;
Self {
anchor,
focus,
anchor_base,
h_pos,
}
}
/// Returns the underlying text range of the selection.
pub fn text_range(&self) -> Range<usize> {
let start = self.anchor.index().min(self.focus.index());
let end = self.focus.index().max(self.anchor.index());
start..end
}
/// Returns a new selection with the focus at the next cluster in visual
/// order.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn next_visual<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
if !self.is_collapsed() && !extend {
let anchor_geom = self.anchor.geometry(layout, 0.0);
let focus_geom = self.focus.geometry(layout, 0.0);
let new_focus = if (anchor_geom.y0, anchor_geom.x0) > (focus_geom.y0, focus_geom.x0) {
self.anchor
} else {
self.focus
};
new_focus.into()
} else {
self.maybe_extend(self.focus.next_visual(layout), extend)
}
}
/// Returns a new selection with the focus at the previous cluster in visual
/// order.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn previous_visual<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
if !self.is_collapsed() && !extend {
let anchor_geom = self.anchor.geometry(layout, 0.0);
let focus_geom = self.focus.geometry(layout, 0.0);
let new_focus = if (anchor_geom.y0, anchor_geom.x0) < (focus_geom.y0, focus_geom.x0) {
self.anchor
} else {
self.focus
};
new_focus.into()
} else {
self.maybe_extend(self.focus.previous_visual(layout), extend)
}
}
/// Returns a new selection with the focus moved to the next word in visual
/// order.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn next_visual_word<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
self.maybe_extend(self.focus.next_visual_word(layout), extend)
}
/// Returns a new selection with the focus moved to the previous word in
/// visual order.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn previous_visual_word<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
self.maybe_extend(self.focus.previous_visual_word(layout), extend)
}
/// Returns a new selection with the focus moved to the next line. The
/// current horizontal position will be maintained.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn next_line<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
self.move_lines(layout, 1, extend)
}
/// Returns a new selection with the focus moved to the previous line. The
/// current horizontal position will be maintained.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn previous_line<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
self.move_lines(layout, -1, extend)
}
/// Returns a new selection with the focus moved the specified number of
/// lines.
///
/// The sign of the `delta` parameter determines the direction to move with
/// negative values moving toward previous lines and positive ones moving
/// toward next lines.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn move_lines<B: Brush>(&self, layout: &Layout<B>, delta: isize, extend: bool) -> Self {
if delta == 0 {
return *self;
}
let line_limit = layout.len().saturating_sub(1);
let geometry = self.focus.geometry(layout, 0.0);
let line_index = layout
.line_for_offset(geometry.y0 as f32)
.map(|(ix, _)| ix)
.unwrap_or(line_limit);
let new_line_index = line_index.saturating_add_signed(delta);
if delta < 0 && line_index.checked_add_signed(delta).is_none() {
return self
.move_to_line(layout, 0, extend)
.line_start(layout, extend);
} else if delta > 0 && new_line_index > line_limit {
return self
.move_to_line(layout, line_limit, extend)
.line_end(layout, extend);
}
self.move_to_line(layout, new_line_index, extend)
}
#[must_use]
fn move_to_line<B: Brush>(&self, layout: &Layout<B>, line_index: usize, extend: bool) -> Self {
let Some(line) = layout.get(line_index) else {
return *self;
};
let h_pos = self
.h_pos
.unwrap_or_else(|| self.focus.geometry(layout, 0.0).x0 as f32);
let y = line.metrics().block_max_coord - line.metrics().ascent * 0.5;
let new_focus = Cursor::from_point(layout, h_pos, y);
let h_pos = Some(h_pos);
if extend {
Self {
anchor: self.anchor,
focus: new_focus,
h_pos,
..Default::default()
}
} else {
Self {
anchor: new_focus,
focus: new_focus,
h_pos,
..Default::default()
}
}
}
/// Returns a new selection with the focus moved to the start of the
/// current line.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn line_start<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
if let Some((_, line)) = self.focus.line(layout) {
self.maybe_extend(
Cursor::from_byte_index(layout, line.text_range().start, Affinity::Downstream),
extend,
)
} else {
*self
}
}
/// Returns a new selection with the focus moved to just after the previous hard line break.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn hard_line_start<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
if let Some((mut hard_line_start_index, line)) = self.focus.line(layout) {
let mut result_byte_index = line.text_range().start;
loop {
if hard_line_start_index == 0 {
break;
}
let prev_index = hard_line_start_index - 1;
let Some(line) = layout.get(prev_index) else {
unreachable!(
"{hard_line_start_index} is a valid line in the layout, but {prev_index} isn't, despite the latter being smaller.\n\
The layout has {} lines.",
layout.len()
);
};
if matches!(line.break_reason(), BreakReason::Explicit) {
// The start of the line 'hard_line_start_index' is the target point.
break;
}
result_byte_index = line.text_range().start;
hard_line_start_index = prev_index;
}
self.maybe_extend(
Cursor::from_byte_index(layout, result_byte_index, Affinity::Downstream),
extend,
)
} else {
*self
}
}
/// Returns a new selection with the focus moved to the end of the
/// current line.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn line_end<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
if let Some((_, line)) = self.focus.line(layout) {
let (index, affinity) = (line.break_reason() == BreakReason::Explicit)
.then(|| {
Cluster::from_byte_index(layout, line.text_range().end - 1)
.map(|cluster| (cluster.text_range().start, Affinity::Downstream))
})
.flatten()
.unwrap_or_else(|| (line.text_range().end, Affinity::Upstream));
self.maybe_extend(Cursor::from_byte_index(layout, index, affinity), extend)
} else {
*self
}
}
/// Returns a new selection with the focus moved to just before the next hard line break.
///
/// If `extend` is `true` then the current anchor will be retained,
/// otherwise the new selection will be collapsed.
#[must_use]
pub fn hard_line_end<B: Brush>(&self, layout: &Layout<B>, extend: bool) -> Self {
if let Some((mut hard_line_end_index, line)) = self.focus.line(layout) {
let mut result_byte_index = line.text_range().end;
// If we're already on the last line of the hard line, use that.
if !matches!(line.break_reason(), BreakReason::Explicit) {
// Otherwise, check if any of the following lines are the last line of the hard line.
loop {
let next_index = hard_line_end_index + 1;
if let Some(line) = layout.get(next_index) {
result_byte_index = line.text_range().end;
hard_line_end_index = next_index;
if matches!(line.break_reason(), BreakReason::Explicit) {
// result_byte_index is the last byte of the previous line, so is the value we need
break;
}
} else {
// We hit the end of text. Select to the end of the "final" line, which was not an EOF.
return self.maybe_extend(
Cursor::from_byte_index(layout, result_byte_index, Affinity::Upstream),
extend,
);
}
}
}
// We want to select to "before" the newline character in the hard line, so we have downstream affinity on the boundary before it.
self.maybe_extend(
Cursor::from_byte_index(layout, result_byte_index - 1, Affinity::Downstream),
extend,
)
} else {
*self
}
}
/// Returns a new selection with the focus extended to the given point.
///
/// If the initial selection was created from a word or line, then the new
/// selection will be extended at the same granularity.
#[must_use]
pub fn extend_to_point<B: Brush>(&self, layout: &Layout<B>, x: f32, y: f32) -> Self {
match self.anchor_base {
AnchorBase::Cluster => Self::new(self.anchor, Cursor::from_point(layout, x, y)),
AnchorBase::Word(start, end) => {
let target = Self::word_from_point(layout, x, y);
let [anchor, focus] = extend_selection(target, [start, end]);
Self {
anchor,
focus,
anchor_base: self.anchor_base,
h_pos: None,
}
}
AnchorBase::Line(start, end) => {
let target = Self::line_from_point(layout, x, y);
let [anchor, focus] = extend_selection(target, [start, end]);
Self {
anchor,
focus,
anchor_base: self.anchor_base,
h_pos: None,
}
}
}
}
/// Returns a new selection with the focus extended to the given point.
#[must_use]
pub fn shift_click_extension<B: Brush>(&self, layout: &Layout<B>, x: f32, y: f32) -> Self {
let target = Cursor::from_point(layout, x, y);
match self.anchor_base {
AnchorBase::Cluster => Self::new(self.anchor, target),
AnchorBase::Word(start, end) | AnchorBase::Line(start, end) => {
// Place the focus where the user just clicked, and the anchor on the "far" side of the anchorbase.
let anchor = if target.index < start.index {
end
} else {
start
};
Self {
anchor,
focus: target,
anchor_base: self.anchor_base,
h_pos: None,
}
}
}
}
/// Returns a new selection with the current anchor and the focus set to
/// the given value.
#[must_use]
pub fn extend(&self, focus: Cursor) -> Self {
Self::new(self.anchor, focus)
}
/// Returns a vector containing the rectangles which represent the visual
/// geometry of this selection for the given layout, and the indices of the
/// lines to which they belong.
///
/// This is a convenience method built on [`geometry_with`](Self::geometry_with).
pub fn geometry<B: Brush>(&self, layout: &Layout<B>) -> Vec<(BoundingBox, usize)> {
let mut rects = Vec::new();
self.geometry_with(layout, |rect, line_idx| rects.push((rect, line_idx)));
rects
}
/// Invokes `f` with the sequence of rectangles which represent the visual
/// geometry of this selection for the given layout, and the indices of the
/// lines to which they belong.
///
/// This avoids allocation if the intent is to render the rectangles
/// immediately.
pub fn geometry_with<B: Brush>(
&self,
layout: &Layout<B>,
mut f: impl FnMut(BoundingBox, usize),
) {
const NEWLINE_WHITESPACE_WIDTH_RATIO: f64 = 0.25;
if self.is_collapsed() {
return;
}
let mut start = self.anchor;
let mut end = self.focus;
if start.index > end.index {
core::mem::swap(&mut start, &mut end);
}
let text_range = start.index..end.index;
let line_start_ix = start.line(layout).map(|(ix, _)| ix).unwrap_or(0);
let line_end_ix = end
.line(layout)
.map(|(ix, _)| ix)
.unwrap_or(layout.len() + 1);
for line_ix in line_start_ix..=line_end_ix {
let Some(line) = layout.get(line_ix) else {
continue;
};
let metrics = line.metrics();
let line_min = metrics.block_min_coord as f64;
let line_max = metrics.block_max_coord as f64;
// Trailing whitespace to indicate that the newline character at the
// end of this line is selected. It's based on the ascent and
// descent so it doesn't change with the line height.
//
// TODO: the width of this whitespace should be the width of a space
// (U+0020) character.
let newline_whitespace = if line.break_reason() == BreakReason::Explicit {
(metrics.ascent as f64 + metrics.descent as f64) * NEWLINE_WHITESPACE_WIDTH_RATIO
} else {
0.0
};
if line_ix == line_start_ix || line_ix == line_end_ix {
// We only need to run the expensive logic on the first and
// last lines
let mut start_x = metrics.offset as f64 + metrics.inline_min_coord as f64;
let mut cur_x = start_x;
let mut cluster_count = 0;
let mut box_advance = 0.0;
let mut have_seen_any_runs = false;
for item in line.items_nonpositioned() {
match item {
LineItem::Run(run) => {
have_seen_any_runs = true;
for cluster in run.visual_clusters() {
let advance = cluster.advance() as f64 + box_advance;
box_advance = 0.0;
if text_range.contains(&cluster.text_range().start) {
cluster_count += 1;
cur_x += advance;
} else {
if cur_x != start_x {
f(
BoundingBox::new(start_x, line_min, cur_x, line_max),
line_ix,
);
}
cur_x += advance;
start_x = cur_x;
}
}
}
LineItem::InlineBox(inline_box) => {
box_advance += inline_box.width as f64;
// HACK: Don't display selections for inline boxes
// if they're the first thing in the line. This
// makes the selection match the cursor position.
if !have_seen_any_runs {
cur_x += box_advance;
box_advance = 0.0;
start_x = cur_x;
}
}
}
}
let mut end_x = cur_x;
if line_ix != line_end_ix || (cluster_count != 0 && metrics.advance == 0.0) {
end_x += newline_whitespace;
}
if end_x != start_x {
f(
BoundingBox::new(start_x, line_min, end_x, line_max),
line_ix,
);
}
} else {
let x = metrics.offset as f64 + metrics.inline_min_coord as f64;
let width = metrics.advance as f64;
f(
BoundingBox::new(x, line_min, x + width + newline_whitespace, line_max),
line_ix,
);
}
}
}
pub(crate) fn maybe_extend(&self, focus: Cursor, extend: bool) -> Self {
if extend {
Self::new(self.anchor, focus)
} else {
focus.into()
}
}
#[cfg(feature = "accesskit")]
pub fn to_access_selection<B: Brush>(
&self,
layout: &Layout<B>,
layout_access: &LayoutAccessibility,
) -> Option<accesskit::TextSelection> {
let anchor = self.anchor.to_access_position(layout, layout_access)?;
let focus = self.focus.to_access_position(layout, layout_access)?;
Some(accesskit::TextSelection { anchor, focus })
}
}
impl PartialEq for Selection {
fn eq(&self, other: &Self) -> bool {
self.anchor == other.anchor && self.focus == other.focus
}
}
impl Eq for Selection {}
impl From<Cursor> for Selection {
fn from(value: Cursor) -> Self {
Self::new(value, value)
}
}
/// Given the anchor base and the target selection, returns the anchor and focus of the resulting selection extension.
fn extend_selection(target_selection: Selection, anchor_base: [Cursor; 2]) -> [Cursor; 2] {
let extending_to_the_right = target_selection.anchor.index >= anchor_base[0].index;
let cursors = [
target_selection.anchor,
target_selection.focus,
anchor_base[0],
anchor_base[1],
];
let mut min = cursors[0];
let mut max = cursors[0];
for pos in cursors {
if pos.index < min.index {
min = pos;
}
if pos.index > max.index {
max = pos;
}
}
if extending_to_the_right {
[min, max]
} else {
[max, min]
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use fontique::Collection;
use fontique::SourceCache;
/// A font database/cache (wrapper around a Fontique [`Collection`] and [`SourceCache`]).
///
/// This type is designed to be a global resource with only one per-application (or per-thread).
#[derive(Default, Clone)]
pub struct FontContext {
pub collection: Collection,
pub source_cache: SourceCache,
}
impl FontContext {
/// Create a new `FontContext`, discovering system fonts if available.
pub fn new() -> Self {
Self::default()
}
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2024 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
/// A box to be laid out inline with text
#[derive(PartialEq, Debug, Clone)]
pub struct InlineBox {
/// User-specified identifier for the box, which can be used by the user to determine which box in
/// parley's output corresponds to which box in its input.
pub id: u64,
/// Whether the box is in-flow (takes up space in the layout) or out-of-flow (e.g. absolutely positioned or floated)
pub kind: InlineBoxKind,
/// The byte offset into the underlying text string at which the box should be placed.
/// This must not be within a Unicode code point.
pub index: usize,
/// The width of the box in pixels
pub width: f32,
/// The height of the box in pixels
pub height: f32,
}
/// Whether a box is in-flow (takes up space in the layout) or out-of-flow (e.g. absolutely positioned)
/// or custom-out-of-flow (line-breaking should yield control flow)
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum InlineBoxKind {
/// `InFlow` boxes take up space in the layout and flow in line with text
///
/// They correspond to `display: inline-block` boxes in CSS.
InFlow,
/// `OutOfFlow` boxes are assigned a position as if they were a zero-sized inline box, but
/// do not take up space in the layout.
///
/// They correspond to `position: absolute` boxes in CSS.
OutOfFlow,
/// `CustomOutOfFlow` boxes also do not take up space in the layout, but they are not assigned a position
/// by Parley. When they are encountered, control flow is yielded back to the caller who is then responsible
/// for laying out the box.
///
/// They can be used to implement advanced layout modes such as CSS's `float`
CustomOutOfFlow,
}
+307
View File
@@ -0,0 +1,307 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use alloc::{
string::{String, ToString},
vec::Vec,
};
use accesskit::{Node, NodeId, Role, TextAlign, TextDirection, TreeUpdate};
use hashbrown::{HashMap, HashSet};
use skrifa::{
FontRef,
raw::{TableProvider, types::NameId},
};
use crate::style::Brush;
use crate::{Alignment, ClusterPath, FontStyle, Layout, LineMetrics, Run, Style};
fn link_spans(prev_id: NodeId, prev: &mut Node, next_id: NodeId, next: &mut Node) {
prev.set_next_on_line(next_id);
next.set_previous_on_line(prev_id);
}
fn finish_span(
node: &mut Node,
x_offset: f64,
y_offset: f64,
metrics: &LineMetrics,
run_offset: f32,
span_offset: f32,
span_advance: f32,
span_text: String,
character_lengths: Vec<u8>,
character_positions: Vec<f32>,
character_widths: Vec<f32>,
word_starts: Vec<u8>,
) {
node.set_bounds(accesskit::Rect {
x0: x_offset + (run_offset + span_offset) as f64,
y0: y_offset + metrics.block_min_coord as f64,
x1: x_offset + (run_offset + span_offset + span_advance) as f64,
y1: y_offset + metrics.block_max_coord as f64,
});
node.set_value(span_text);
node.set_character_lengths(character_lengths);
node.set_character_positions(character_positions);
node.set_character_widths(character_widths);
node.set_word_starts(word_starts);
}
fn add_span(update: &mut TreeUpdate, parent_node: &mut Node, id: NodeId, node: Node) {
update.nodes.push((id, node));
parent_node.push_child(id);
}
#[derive(Clone, Default, Debug)]
pub struct LayoutAccessibility {
// We define a span as a sequence of clusters, in logical order, that all
// have an identical style. For each span we create an AccessKit node
// with the `TextRun` role, and these nodes are in logical order.
// The following two fields maintain a two-way mapping between spans
// and AccessKit node IDs, where each span is identified by the path to
// its first cluster, or a span path for short. These maps are maintained by
// `LayoutAccess::build_nodes`, which ensures that removed spans are removed
// from the maps on the next accessibility pass.
pub(crate) access_ids_by_span_path: HashMap<ClusterPath, NodeId>,
pub(crate) span_paths_by_access_id: HashMap<NodeId, ClusterPath>,
// Map from cluster path to span path. This allows `Cursor::to_access_position`
// to complete in O(1), rather than worst-case O(n) where n is the length
// of the run. It also means that the logic for when to start a new span,
// including the limitation on the number of characters per span,
// only needs to live in `LayoutAccess::build_nodes`.
pub(crate) span_paths_by_cluster_path: HashMap<ClusterPath, ClusterPath>,
}
impl LayoutAccessibility {
fn span_id_and_node<B: Brush>(
&mut self,
next_node_id: &mut impl FnMut() -> NodeId,
ids: &mut HashSet<NodeId>,
run: &Run<'_, B>,
span_path: ClusterPath,
) -> (NodeId, Node) {
// If we encountered this same span path in the previous
// accessibility pass, reuse the same AccessKit ID. Otherwise,
// allocate a new one. This enables stable node IDs when merely
// updating the content of existing spans.
let id = self
.access_ids_by_span_path
.get(&span_path)
.copied()
.unwrap_or_else(|| {
let id = (*next_node_id)();
self.access_ids_by_span_path.insert(span_path, id);
self.span_paths_by_access_id.insert(id, span_path);
id
});
ids.insert(id);
let mut node = Node::new(Role::TextRun);
node.set_text_direction(if run.is_rtl() {
TextDirection::RightToLeft
} else {
TextDirection::LeftToRight
});
let font = run.font();
if let Ok(font_ref) = FontRef::from_index(font.data.as_ref(), font.index) {
if let Ok(name) = font_ref.name() {
for n in name.name_record().iter() {
if n.name_id.get() == NameId::FAMILY_NAME {
if let Ok(string) = n.string(name.string_data()) {
node.set_font_family(string.to_string());
}
break;
}
}
}
}
node.set_font_size(run.font_size());
let attrs = run.font_attrs();
node.set_font_weight(attrs.weight.value());
if matches!(attrs.style, FontStyle::Italic) {
node.set_italic();
}
if let Some(align) = run.layout.data.alignment {
node.set_text_align(match align {
Alignment::Start => {
if run.is_rtl() {
TextAlign::Right
} else {
TextAlign::Left
}
}
Alignment::End => {
if run.is_rtl() {
TextAlign::Left
} else {
TextAlign::Right
}
}
Alignment::Left => TextAlign::Left,
Alignment::Center => TextAlign::Center,
Alignment::Right => TextAlign::Right,
Alignment::Justify => TextAlign::Justify,
});
}
(id, node)
}
#[allow(clippy::too_many_arguments)]
pub fn build_nodes<B: Brush>(
&mut self,
text: &str,
layout: &Layout<B>,
update: &mut TreeUpdate,
parent_node: &mut Node,
mut next_node_id: impl FnMut() -> NodeId,
x_offset: f64,
y_offset: f64,
set_brush_properties: impl Fn(&mut Node, &Style<B>),
) {
self.span_paths_by_cluster_path.clear();
// Build a set of node IDs for the runs encountered in this pass.
let mut ids = HashSet::<NodeId>::new();
// Reuse scratch space for storing a sorted list of runs.
let mut runs = Vec::new();
for (line_index, line) in layout.lines().enumerate() {
let metrics = line.metrics();
// Defer adding each run node until we reach either the next run
// or the end of the line. That way, we can set relations between
// runs in a line and do anything special that might be required
// for the last run in a line.
let mut last_node: Option<(NodeId, Node)> = None;
// Iterate over the runs from left to right, computing their offsets,
// then sort them into text order.
runs.clear();
runs.reserve(line.len());
{
let mut run_offset = metrics.offset;
for run in line.runs() {
let advance = run.advance();
runs.push((run, run_offset));
run_offset += advance;
}
}
runs.sort_by_key(|(r, _)| r.text_range().start);
for (run, run_offset) in runs.drain(..) {
let mut span_path = ClusterPath::new(line_index as u32, run.index() as u32, 0);
let (mut id, mut node) =
self.span_id_and_node(&mut next_node_id, &mut ids, &run, span_path);
if let Some((last_id, mut last_node)) = last_node.take() {
link_spans(last_id, &mut last_node, id, &mut node);
add_span(update, parent_node, last_id, last_node);
}
let mut prev_style_index: Option<u16> = None;
let mut span_text = String::new();
let mut character_lengths = Vec::new();
let mut span_offset = 0.0;
let mut span_advance = 0.0;
let mut character_positions = Vec::new();
let mut character_widths = Vec::new();
let mut word_starts = Vec::new();
for cluster in run.clusters() {
let style_index = cluster.data.style_index;
if let Some(prev_index) = prev_style_index {
// Limit spans to 256 characters because `word_starts`
// consists of `u8`s.
if prev_index != style_index || character_lengths.len() >= 256 {
prev_style_index = None;
finish_span(
&mut node,
x_offset,
y_offset,
metrics,
run_offset,
span_offset,
span_advance,
span_text.clone(),
character_lengths.clone(),
character_positions.clone(),
character_widths.clone(),
word_starts.clone(),
);
span_offset += span_advance;
span_advance = 0.0;
span_text.clear();
character_lengths.clear();
character_positions.clear();
character_widths.clear();
word_starts.clear();
(id, node) = {
let (old_id, mut old_node) = (id, node);
span_path = cluster.path();
let (new_id, mut new_node) = self.span_id_and_node(
&mut next_node_id,
&mut ids,
&run,
span_path,
);
link_spans(old_id, &mut old_node, new_id, &mut new_node);
add_span(update, parent_node, old_id, old_node);
(new_id, new_node)
};
}
}
if prev_style_index.is_none() {
prev_style_index = Some(style_index);
let style = cluster.first_style();
set_brush_properties(&mut node, style);
if let Some(locale) = &style.locale {
node.set_language(locale.as_str());
}
}
let cluster_text = &text[cluster.text_range()];
span_text.push_str(cluster_text);
if cluster.is_word_boundary() && !cluster.is_space_or_nbsp() {
word_starts.push(character_lengths.len() as _);
}
character_lengths.push(cluster_text.len() as _);
character_positions.push(span_advance);
character_widths.push(cluster.advance());
span_advance += cluster.advance();
self.span_paths_by_cluster_path
.insert(cluster.path(), span_path);
}
finish_span(
&mut node,
x_offset,
y_offset,
metrics,
run_offset,
span_offset,
span_advance,
span_text,
character_lengths,
character_positions,
character_widths,
word_starts,
);
last_node = Some((id, node));
}
if let Some((id, node)) = last_node {
add_span(update, parent_node, id, node);
}
}
// Remove mappings for spans that no longer exist.
self.span_paths_by_access_id.retain(|access_id, span_path| {
let keep = ids.contains(access_id);
if !keep {
self.access_ids_by_span_path.remove(span_path);
}
keep
});
}
}
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2024 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use super::{
BreakReason,
data::{ClusterData, LineItemData},
};
use crate::data::LayoutData;
use crate::style::Brush;
/// Alignment of a layout.
#[derive(Copy, Clone, Default, PartialEq, Eq, Debug)]
#[repr(u8)]
pub enum Alignment {
/// This is [`Alignment::Left`] for LTR text and [`Alignment::Right`] for RTL text.
#[default]
Start,
/// This is [`Alignment::Right`] for LTR text and [`Alignment::Left`] for RTL text.
End,
/// Align content to the left edge.
///
/// For alignment that should be aware of text direction, use [`Alignment::Start`] or
/// [`Alignment::End`] instead.
Left,
/// Align each line centered within the container.
Center,
/// Align content to the right edge.
///
/// For alignment that should be aware of text direction, use [`Alignment::Start`] or
/// [`Alignment::End`] instead.
Right,
/// Justify each line by spacing out content, except for the last line.
Justify,
}
/// Additional options to fine tune alignment
#[derive(Debug, Clone, Copy)]
pub struct AlignmentOptions {
/// If set to `true`, "end" and "center" alignment will apply even if the line contents are
/// wider than the alignment width. If it is set to `false`, all overflowing lines will be
/// [`Alignment::Start`] aligned.
pub align_when_overflowing: bool,
}
#[expect(
clippy::derivable_impls,
reason = "Make default values explicit rather than relying on the implicit default value of bool"
)]
impl Default for AlignmentOptions {
fn default() -> Self {
Self {
align_when_overflowing: false,
}
}
}
/// Align the layout.
///
/// If [`Alignment::Justify`] is requested, clusters' [`ClusterData::advance`] will be adjusted.
/// Prior to re-line-breaking or re-aligning, [`unjustify`] has to be called.
pub(crate) fn align<B: Brush>(
layout: &mut LayoutData<B>,
alignment: Alignment,
options: AlignmentOptions,
) {
#[cfg(feature = "accesskit")]
{
layout.alignment = Some(alignment);
}
layout.is_aligned_justified = alignment == Alignment::Justify;
align_impl::<_, false>(layout, alignment, options);
}
/// Removes previous justification applied to clusters.
///
/// This is part of resetting state in preparation for re-line-breaking or re-aligning the same
/// layout.
pub(crate) fn unjustify<B: Brush>(layout: &mut LayoutData<B>) {
if layout.is_aligned_justified {
align_impl::<_, true>(layout, Alignment::Justify, AlignmentOptions::default());
layout.is_aligned_justified = false;
}
}
/// The actual alignment implementation.
///
/// This is const-generic over `UNDO_JUSTIFICATION`: justified alignment adjusts clusters'
/// [`ClusterData::advance`], and this mutation has to be undone for re-line-breaking or
/// re-aligning. `UNDO_JUSTIFICATION` indicates whether the adjustment has to be applied, or
/// undone.
///
/// Writing a separate function for undoing justification would be faster, but not by much, and
/// doing it this way we are sure the calculations performed are equivalent.
fn align_impl<B: Brush, const UNDO_JUSTIFICATION: bool>(
layout: &mut LayoutData<B>,
alignment: Alignment,
options: AlignmentOptions,
) {
// Whether the text base direction is right-to-left.
let is_rtl = layout.base_level & 1 == 1;
// Apply alignment to line items
for line in &mut layout.lines {
let indent = line.indent;
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;
if !options.align_when_overflowing && free_space <= 0.0 {
if is_rtl {
// In RTL text, right-align on overflow.
line.metrics.offset += free_space;
}
continue;
}
match (alignment, is_rtl) {
(Alignment::Left, _) | (Alignment::Start, false) | (Alignment::End, true) => {
// Do nothing
}
(Alignment::Right, _) | (Alignment::Start, true) | (Alignment::End, false) => {
line.metrics.offset += free_space;
}
(Alignment::Center, _) => {
line.metrics.offset += free_space * 0.5;
}
(Alignment::Justify, _) => {
// Justified alignment doesn't have any effect if free_space is negative or zero
if free_space <= 0.0 {
continue;
}
// 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
{
if is_rtl {
line.metrics.offset += free_space;
}
continue;
}
let adjustment =
free_space / line.num_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)
// - Iterate backwards for odd bidi levels (which represent RTL runs)
let line_items: &mut dyn Iterator<Item = &LineItemData> = if is_rtl {
&mut layout.line_items[line.item_range.clone()].iter().rev()
} else {
&mut layout.line_items[line.item_range.clone()].iter()
};
line_items
.filter(|item| item.is_text_run())
.for_each(|line_item| {
let clusters = &mut layout.clusters[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 {
&mut clusters.iter_mut().rev()
} else {
&mut clusters.iter_mut()
};
clusters.for_each(|cluster| {
if applied == line.num_spaces {
return;
}
if cluster.info.whitespace().is_space_or_nbsp() {
cluster.advance += adjustment;
applied += 1;
}
});
});
}
}
}
}
+604
View File
@@ -0,0 +1,604 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::analysis::cluster::Whitespace;
use crate::layout::Style;
use crate::layout::data::BreakReason;
use crate::layout::data::ClusterData;
use crate::layout::glyph::Glyph;
use crate::layout::layout::Layout;
use crate::layout::line::{Line, LineItem};
use crate::layout::run::Run;
use crate::style::Brush;
use core::ops::Range;
/// Atomic unit of text.
#[derive(Copy, Clone)]
pub struct Cluster<'a, B: Brush> {
pub(crate) path: ClusterPath,
pub(crate) run: Run<'a, B>,
pub(crate) data: &'a ClusterData,
}
/// Defines the visual side of the cluster for hit testing.
///
/// See [`Cluster::from_point`].
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum ClusterSide {
/// Cluster was hit on the left half.
Left,
/// Cluster was hit on the right half.
Right,
}
impl<'a, B: Brush> Cluster<'a, B> {
/// Returns the cluster for the given layout and byte index.
pub fn from_byte_index(layout: &'a Layout<B>, byte_index: usize) -> Option<Self> {
let mut path = ClusterPath::default();
if let Some((line_index, line)) = layout.line_for_byte_index(byte_index) {
path.line_index = line_index as u32;
for run in line.runs() {
path.run_index = run.index;
if !run.text_range().contains(&byte_index) {
continue;
}
for (cluster_index, cluster) in run.clusters().enumerate() {
path.logical_index = cluster_index as u32;
if cluster.text_range().contains(&byte_index) {
return path.cluster(layout);
}
}
}
}
None
}
/// Returns the cluster and side which is at the specified position in the given layout. If no cluster is
/// under the specified point then None will be returned.
///
/// This is usually the expected behaviour when hit-testing clusters for "hover" or "click" functionality.
pub fn from_point_exact(layout: &'a Layout<B>, x: f32, y: f32) -> Option<(Self, ClusterSide)> {
Cluster::from_point_impl(layout, x, y, true)
}
/// Returns the cluster and side which is at the specified position in the given layout. If no cluster is
/// under the specified point but the point is within the overall layout area then it will return the nearest.
///
/// This is usually the expected behaviour when hit-testing clusers for text selection or caret positioning.
pub fn from_point(layout: &'a Layout<B>, x: f32, y: f32) -> Option<(Self, ClusterSide)> {
Cluster::from_point_impl(layout, x, y, false)
}
/// Returns the cluster and side for the given layout and point.
fn from_point_impl(
layout: &'a Layout<B>,
x: f32,
y: f32,
exact: bool,
) -> Option<(Self, ClusterSide)> {
let mut path = ClusterPath::default();
if let Some((line_index, line)) = layout.line_for_offset(y) {
path.line_index = line_index as u32;
let mut offset = line.metrics().offset + line.metrics().inline_min_coord;
let last_run_index = line.len().saturating_sub(1);
for item in line.items_nonpositioned() {
match item {
LineItem::Run(run) => {
let is_last_run = run.index as usize == last_run_index;
let run_advance = run.advance();
path.run_index = run.index;
path.logical_index = 0;
if x > offset + run_advance && (exact || !is_last_run) {
offset += run_advance;
continue;
}
let last_cluster_index = run.cluster_range().len().saturating_sub(1);
for (visual_index, cluster) in run.visual_clusters().enumerate() {
let is_last_cluster = is_last_run && visual_index == last_cluster_index;
path.logical_index =
run.visual_to_logical(visual_index).unwrap_or_default() as u32;
let cluster_advance = cluster.advance();
let edge = offset;
offset += cluster_advance;
if x > offset && (exact || !is_last_cluster) {
continue;
}
if x < edge && exact {
continue;
}
let side = if x <= edge + cluster_advance * 0.5 {
ClusterSide::Left
} else {
ClusterSide::Right
};
return Some((path.cluster(layout)?, side));
}
}
LineItem::InlineBox(inline_box) => {
offset += inline_box.width;
}
}
}
}
if y <= 0.0 && !exact {
Some((path.cluster(layout)?, ClusterSide::Left))
} else {
None
}
}
/// Returns the line that contains the cluster.
pub fn line(&self) -> Line<'a, B> {
self.run.layout.get(self.run.line_index as usize).unwrap()
}
/// Returns the run that contains the cluster.
pub fn run(&self) -> Run<'a, B> {
self.run.clone()
}
/// Returns the path that contains the set of indices to reach the cluster
/// from a layout.
pub fn path(&self) -> ClusterPath {
self.path
}
/// Returns the range of text that defines the cluster.
pub fn text_range(&self) -> Range<usize> {
self.data.text_range(self.run.data)
}
/// Returns the first style that applies to the cluster. If the cluster contains multiple glyphs
/// then this style may not apply to all glyphs in the cluster (see the `DIVERGENT_STYLES` flag)
pub fn first_style(&self) -> &Style<B> {
&self.run.layout.styles()[usize::from(self.data.style_index)]
}
/// Returns the advance of the cluster.
pub fn advance(&self) -> f32 {
self.data.advance
}
/// Returns `true` if this is a right-to-left cluster.
pub fn is_rtl(&self) -> bool {
self.run.is_rtl()
}
/// Returns `true` if the cluster is the beginning of a ligature.
pub fn is_ligature_start(&self) -> bool {
self.data.is_ligature_start()
}
/// Returns `true` if the cluster is a ligature continuation.
pub fn is_ligature_continuation(&self) -> bool {
self.data.is_ligature_component()
}
/// Returns `true` if the cluster is a word boundary.
pub fn is_word_boundary(&self) -> bool {
self.data.info.is_boundary()
}
/// Returns `true` if the cluster is a soft line break.
pub fn is_soft_line_break(&self) -> bool {
self.is_end_of_line()
&& matches!(
self.line().data.break_reason,
BreakReason::Regular | BreakReason::Emergency
)
}
/// Returns `true` if the cluster is a hard line break.
pub fn is_hard_line_break(&self) -> bool {
self.data.info.whitespace() == Whitespace::Newline
}
/// Returns `true` if the cluster is a space or no-break space.
pub fn is_space_or_nbsp(&self) -> bool {
self.data.info.whitespace().is_space_or_nbsp()
}
/// Returns `true` if the cluster is an emoji sequence.
pub fn is_emoji(&self) -> bool {
self.data.info.is_emoji()
}
/// Returns an iterator over the glyphs in the cluster.
pub fn glyphs(&self) -> impl Iterator<Item = Glyph> + 'a + Clone {
if self.data.glyph_len == 0xFF {
GlyphIter::Single(Some(Glyph {
id: self.data.glyph_offset,
style_index: self.data.style_index,
x: 0.,
y: 0.,
advance: self.data.advance,
}))
} else {
let start = self.run.data.glyph_start + self.data.glyph_offset as usize;
GlyphIter::Slice(
self.run.layout.data.glyphs[start..start + self.data.glyph_len as usize].iter(),
)
}
}
/// Returns `true` if this cluster is at the beginning of a line.
pub fn is_start_of_line(&self) -> bool {
self.path.run_index == 0 && self.run.logical_to_visual(self.path.logical_index()) == Some(0)
}
/// Returns `true` if this cluster is at the end of a line.
pub fn is_end_of_line(&self) -> bool {
self.line().len().saturating_sub(1) == self.path.run_index()
&& self.run.logical_to_visual(self.path.logical_index())
== Some(self.run.cluster_range().len().saturating_sub(1))
}
/// If the cluster as at the end of the line, returns the reason
/// for the line break.
pub fn is_line_break(&self) -> Option<BreakReason> {
if self.is_end_of_line() {
Some(self.line().data.break_reason)
} else {
None
}
}
/// Returns the cluster that follows this one in logical order.
pub fn next_logical(&self) -> Option<Self> {
if self.path.logical_index() + 1 < self.run.cluster_range().len() {
// Fast path: next cluster is in the same run
ClusterPath {
line_index: self.path.line_index,
run_index: self.path.run_index,
logical_index: self.path.logical_index + 1,
}
.cluster(self.run.layout)
} else {
let index = self.text_range().end;
if index >= self.run.layout.data.text_len {
return None;
}
// We have to search for the cluster containing our end index
Self::from_byte_index(self.run.layout, index)
}
}
/// Returns the cluster that precedes this one in logical order.
pub fn previous_logical(&self) -> Option<Self> {
if self.path.logical_index > 0 {
// Fast path: previous cluster is in the same run
ClusterPath {
line_index: self.path.line_index,
run_index: self.path.run_index,
logical_index: self.path.logical_index - 1,
}
.cluster(self.run.layout)
} else {
Self::from_byte_index(self.run.layout, self.text_range().start.checked_sub(1)?)
}
}
/// Returns the cluster that follows this one in visual order.
pub fn next_visual(&self) -> Option<Self> {
let layout = self.run.layout;
let run = self.run.clone();
let visual_index = run.logical_to_visual(self.path.logical_index())?;
if let Some(cluster_index) = run.visual_to_logical(visual_index + 1) {
// Fast path: next visual cluster is in the same run
run.get(cluster_index)
} else {
// We just want to find the first line/run following this one that
// contains any cluster.
let mut run_index = self.path.run_index() + 1;
for line_index in self.path.line_index()..layout.len() {
let line = layout.get(line_index)?;
for run_index in run_index..line.len() {
if let Some(run) = line.item(run_index).and_then(|item| item.run()) {
if !run.cluster_range().is_empty() {
return ClusterPath {
line_index: line_index as u32,
run_index: run_index as u32,
logical_index: run.visual_to_logical(0)? as u32,
}
.cluster(layout);
}
}
}
// Restart at first run on next line
run_index = 0;
}
None
}
}
/// Returns the cluster that precedes this one in visual order.
pub fn previous_visual(&self) -> Option<Self> {
let visual_index = self.run.logical_to_visual(self.path.logical_index())?;
if let Some(cluster_index) = visual_index
.checked_sub(1)
.and_then(|visual_index| self.run.visual_to_logical(visual_index))
{
// Fast path: previous visual cluster is in the same run
ClusterPath {
line_index: self.path.line_index,
run_index: self.path.run_index,
logical_index: cluster_index as u32,
}
.cluster(self.run.layout)
} else {
// We just want to find the first line/run preceding this one that
// contains any cluster.
let layout = self.run.layout;
let mut run_index = Some(self.path.run_index());
for line_index in (0..=self.path.line_index()).rev() {
let line = layout.get(line_index)?;
let first_run = run_index.unwrap_or(line.len());
for run_index in (0..first_run).rev() {
if let Some(run) = line.item(run_index).and_then(|item| item.run()) {
let range = run.cluster_range();
if !range.is_empty() {
return ClusterPath {
line_index: line_index as u32,
run_index: run_index as u32,
logical_index: run.visual_to_logical(range.len() - 1)? as u32,
}
.cluster(layout);
}
}
}
run_index = None;
}
None
}
}
/// Returns the next cluster that is marked as a word boundary.
pub fn next_logical_word(&self) -> Option<Self> {
let mut cluster = self.clone();
while let Some(next) = cluster.next_logical() {
if next.is_word_boundary() {
return Some(next);
}
cluster = next;
}
None
}
/// Returns the next cluster that is marked as a word boundary.
pub fn next_visual_word(&self) -> Option<Self> {
let mut cluster = self.clone();
while let Some(next) = cluster.next_visual() {
if next.is_word_boundary() {
return Some(next);
}
cluster = next;
}
None
}
/// Returns the previous cluster that is marked as a word boundary.
pub fn previous_logical_word(&self) -> Option<Self> {
let mut cluster = self.clone();
while let Some(prev) = cluster.previous_logical() {
if prev.is_word_boundary() {
return Some(prev);
}
cluster = prev;
}
None
}
/// Returns the previous cluster that is marked as a word boundary.
pub fn previous_visual_word(&self) -> Option<Self> {
let mut cluster = self.clone();
while let Some(prev) = cluster.previous_visual() {
if prev.is_word_boundary() {
return Some(prev);
}
cluster = prev;
}
None
}
/// Returns the visual offset of this cluster along direction of text flow.
///
/// This cost of this function is roughly linear in the number of clusters
/// on the containing line.
pub fn visual_offset(&self) -> Option<f32> {
let line = self.path.line(self.run.layout)?;
let mut offset = line.metrics().offset;
for run_index in 0..=self.path.run_index() {
let item = line.item(run_index)?;
match item {
LineItem::Run(run) => {
if run_index != self.path.run_index() {
offset += run.advance();
} else {
let visual_index = run.logical_to_visual(self.path.logical_index())?;
for cluster in run.visual_clusters().take(visual_index) {
offset += cluster.advance();
}
}
}
LineItem::InlineBox(inline_box) => {
offset += inline_box.width;
}
}
}
Some(offset)
}
pub(crate) fn info(&self) -> &super::data::ClusterInfo {
&self.data.info
}
/// Returns the text length of the cluster in bytes.
///
/// This is only used for tests, and is *not* part of the public API.
#[doc(hidden)]
pub fn text_len(&self) -> u8 {
self.data.text_len
}
/// Returns this cluster's original character.
///
/// This is only used for tests, and is *not* part of the public API.
#[doc(hidden)]
pub fn source_char(&self) -> char {
self.data.info.source_char()
}
}
/// Determines how a cursor attaches to a cluster.
#[derive(Copy, Clone, PartialEq, Eq, Default, Debug)]
pub enum Affinity {
/// Cursor is attached to the character that is logically following in the
/// text stream.
#[default]
Downstream = 0,
/// Cursor is attached to the character that is logically preceding in the
/// text stream.
Upstream = 1,
}
impl Affinity {
#[must_use]
pub fn invert(&self) -> Self {
match self {
Self::Downstream => Self::Upstream,
Self::Upstream => Self::Downstream,
}
}
}
/// Index based path to a cluster.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Default, Debug)]
pub struct ClusterPath {
pub(crate) line_index: u32,
pub(crate) run_index: u32,
pub(crate) logical_index: u32,
}
impl ClusterPath {
pub(crate) fn new(line_index: u32, run_index: u32, logical_index: u32) -> Self {
Self {
line_index,
run_index,
logical_index,
}
}
/// Returns the index of the line containing this cluster.
pub fn line_index(&self) -> usize {
self.line_index as usize
}
/// Returns the index of the run (within the owning line) containing this
/// cluster.
pub fn run_index(&self) -> usize {
self.run_index as usize
}
/// Returns the logical index of the cluster within the owning run.
pub fn logical_index(&self) -> usize {
self.logical_index as usize
}
/// Returns the line for this path and the specified layout.
pub fn line<'a, B: Brush>(&self, layout: &'a Layout<B>) -> Option<Line<'a, B>> {
layout.get(self.line_index())
}
/// Returns the run for this path and the specified layout.
pub fn run<'a, B: Brush>(&self, layout: &'a Layout<B>) -> Option<Run<'a, B>> {
self.line(layout)?.item(self.run_index())?.run()
}
/// Returns the cluster for this path and the specified layout.
pub fn cluster<'a, B: Brush>(&self, layout: &'a Layout<B>) -> Option<Cluster<'a, B>> {
self.run(layout)?.get(self.logical_index())
}
}
#[derive(Clone)]
enum GlyphIter<'a> {
Single(Option<Glyph>),
Slice(core::slice::Iter<'a, Glyph>),
}
impl Iterator for GlyphIter<'_> {
type Item = Glyph;
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Single(glyph) => glyph.take(),
Self::Slice(iter) => {
let glyph = *iter.next()?;
Some(glyph)
}
}
}
}
#[cfg(test)]
mod tests {
use crate::{
Alignment, AlignmentOptions, Cluster, FontContext, Layout, LayoutContext,
PositionedLayoutItem, StyleProperty,
};
type Brush = ();
fn create_unaligned_layout() -> Layout<Brush> {
let mut layout_ctx = LayoutContext::new();
// TODO: Use a test font
let mut font_ctx = FontContext::new();
let text = "Parley exists";
let mut builder = layout_ctx.ranged_builder(&mut font_ctx, text, 1.0, true);
builder.push_default(StyleProperty::FontSize(10.));
let mut layout = builder.build(text);
layout.break_all_lines(None);
layout
}
fn cluster_from_position_with_alignment(alignment: Alignment) {
let mut layout = create_unaligned_layout();
layout.align(alignment, AlignmentOptions::default());
assert_eq!(
layout.len(),
1,
"Text doesn't contain any newlines, and there's no max advance"
);
let line = layout.get(0).unwrap();
let mut test_count = 0;
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
unreachable!("No inline boxes set up");
};
for glyph in run.positioned_glyphs() {
test_count += 1;
let cluster = Cluster::from_point(&layout, glyph.x + 0.1, glyph.y).unwrap();
assert_eq!(cluster.0.glyphs().next().unwrap().id, glyph.id);
}
}
assert!(test_count > 5);
}
#[test]
fn cluster_from_position_start_alignment() {
cluster_from_position_with_alignment(Alignment::Start);
}
#[test]
fn cluster_from_position_center_alignment() {
cluster_from_position_with_alignment(Alignment::Center);
}
#[test]
fn cluster_from_position_end_alignment() {
cluster_from_position_with_alignment(Alignment::End);
}
#[test]
fn cluster_from_position_justified_alignment() {
cluster_from_position_with_alignment(Alignment::Justify);
}
}
+962
View File
@@ -0,0 +1,962 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::inline_box::InlineBox;
use crate::layout::{ContentWidths, Glyph, LineMetrics, RunMetrics, Style};
use crate::style::Brush;
use crate::util::nearly_zero;
use crate::{FontData, IndentOptions, InlineBoxKind, LineHeight, OverflowWrap, TextWrapMode};
use core::ops::Range;
use alloc::vec::Vec;
use crate::analysis::cluster::Whitespace;
use crate::analysis::{Boundary, CharInfo};
#[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) struct ClusterData {
pub(crate) info: ClusterInfo,
/// Cluster flags (see impl methods for details).
pub(crate) flags: u16,
/// Style index for this cluster.
pub(crate) style_index: u16,
/// Number of glyphs in this cluster (0xFF = single glyph stored inline)
pub(crate) glyph_len: u8,
/// Number of text bytes in this cluster
pub(crate) text_len: u8,
/// If `glyph_len == 0xFF`, then `glyph_offset` is a glyph identifier,
/// otherwise, it's an offset into the glyph array with the base
/// taken from the owning run.
pub(crate) glyph_offset: u32,
/// Offset into the text for this cluster
pub(crate) text_offset: u16,
/// Advance width for this cluster
pub(crate) advance: f32,
}
impl ClusterData {
pub(crate) const LIGATURE_START: u16 = 1;
pub(crate) const LIGATURE_COMPONENT: u16 = 2;
#[inline(always)]
pub(crate) fn is_ligature_start(self) -> bool {
self.flags & Self::LIGATURE_START != 0
}
#[inline(always)]
pub(crate) fn is_ligature_component(self) -> bool {
self.flags & Self::LIGATURE_COMPONENT != 0
}
#[inline(always)]
pub(crate) fn text_range(self, run: &RunData) -> Range<usize> {
let start = run.text_range.start + self.text_offset as usize;
start..start + self.text_len as usize
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) struct ClusterInfo {
boundary: Boundary,
source_char: char,
}
impl ClusterInfo {
pub(crate) fn new(boundary: Boundary, source_char: char) -> Self {
Self {
boundary,
source_char,
}
}
// Returns the boundary type of the cluster.
pub(crate) fn boundary(self) -> Boundary {
self.boundary
}
// Returns the whitespace type of the cluster.
pub(crate) fn whitespace(self) -> Whitespace {
to_whitespace(self.source_char)
}
/// Returns if the cluster is a line boundary.
pub(crate) fn is_boundary(self) -> bool {
self.boundary != Boundary::None
}
/// Returns if the cluster is an emoji.
pub(crate) fn is_emoji(self) -> bool {
// TODO: Defer to ICU4X properties (see: https://docs.rs/icu/latest/icu/properties/props/struct.Emoji.html).
matches!(self.source_char as u32, 0x1F600..=0x1F64F | 0x1F300..=0x1F5FF | 0x1F680..=0x1F6FF | 0x2600..=0x26FF | 0x2700..=0x27BF)
}
/// Returns if the cluster is any whitespace.
pub(crate) fn is_whitespace(self) -> bool {
self.source_char.is_whitespace()
}
/// Returns the cluster's original character.
pub(crate) fn source_char(self) -> char {
self.source_char
}
}
const fn to_whitespace(c: char) -> Whitespace {
const LINE_SEPARATOR: char = '\u{2028}';
const PARAGRAPH_SEPARATOR: char = '\u{2029}';
match c {
' ' => Whitespace::Space,
'\t' => Whitespace::Tab,
'\n' | '\r' | LINE_SEPARATOR | PARAGRAPH_SEPARATOR => Whitespace::Newline,
'\u{00A0}' => Whitespace::NoBreakSpace,
_ => Whitespace::None,
}
}
/// `HarfRust`-based run data
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct RunData {
/// Index of the font for the run.
pub(crate) font_index: usize,
/// Font size.
pub(crate) font_size: f32,
/// Font attributes, needed for accessibility.
pub(crate) font_attrs: fontique::Attributes,
/// Synthesis for rendering (contains variation settings)
pub(crate) synthesis: fontique::Synthesis,
/// Range of normalized coordinates in the layout data.
pub(crate) coords_range: Range<usize>,
/// Range of the source text.
pub(crate) text_range: Range<usize>,
/// Bidi level for the run.
pub(crate) bidi_level: u8,
/// Range of clusters.
pub(crate) cluster_range: Range<usize>,
/// Base for glyph indices.
pub(crate) glyph_start: usize,
/// Metrics for the run.
pub(crate) metrics: RunMetrics,
/// Additional word spacing.
pub(crate) word_spacing: f32,
/// Additional letter spacing.
pub(crate) letter_spacing: f32,
/// Total advance of the run.
pub(crate) advance: f32,
}
#[derive(Copy, Clone, Default, PartialEq, Debug)]
pub enum BreakReason {
#[default]
None,
Regular,
Explicit,
Emergency,
}
#[derive(Clone, Default, Debug, PartialEq)]
pub(crate) struct LineData {
/// Range of the source text.
pub(crate) text_range: Range<usize>,
/// Range of line items.
pub(crate) item_range: Range<usize>,
/// Metrics for the line.
pub(crate) metrics: LineMetrics,
/// The cause of the line break.
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,
/// Text indent applied to this line.
pub(crate) indent: f32,
}
impl LineData {
pub(crate) fn size(&self) -> f32 {
self.metrics.ascent + self.metrics.descent + self.metrics.leading
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LineItemData {
/// Whether the item is a run or an inline box
pub(crate) kind: LayoutItemKind,
/// 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,
/// Advance (size in direction of text flow) for the run.
pub(crate) advance: f32,
// Fields that only apply to text runs (Ignored for boxes)
// TODO: factor this out?
/// True if the run is composed entirely of whitespace.
pub(crate) is_whitespace: bool,
/// True if the run ends in whitespace.
pub(crate) has_trailing_whitespace: bool,
/// Range of the source text.
pub(crate) text_range: Range<usize>,
/// Range of clusters.
pub(crate) cluster_range: Range<usize>,
}
impl LineItemData {
pub(crate) fn is_text_run(&self) -> bool {
self.kind == LayoutItemKind::TextRun
}
#[inline(always)]
pub(crate) fn is_rtl(&self) -> bool {
self.bidi_level & 1 != 0
}
/// If the item is a text run
/// - Determine if it consists entirely of whitespace (`is_whitespace` property)
/// - Determine if it has trailing whitespace (`has_trailing_whitespace` property)
pub(crate) fn compute_whitespace_properties<B: Brush>(&mut self, layout_data: &LayoutData<B>) {
// Skip items which are not text runs
if self.kind != LayoutItemKind::TextRun {
return;
}
self.is_whitespace = true;
if self.is_rtl() {
// RTL runs check for "trailing" whitespace at the front.
for cluster in layout_data.clusters[self.cluster_range.clone()].iter() {
if cluster.info.is_whitespace() {
self.has_trailing_whitespace = true;
} else {
self.is_whitespace = false;
break;
}
}
} else {
for cluster in layout_data.clusters[self.cluster_range.clone()]
.iter()
.rev()
{
if cluster.info.is_whitespace() {
self.has_trailing_whitespace = true;
} else {
self.is_whitespace = false;
break;
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LayoutItemKind {
TextRun,
InlineBox,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LayoutItem {
/// Whether the item is a run or an inline box
pub(crate) kind: LayoutItemKind,
/// 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,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct LayoutData<B: Brush> {
// General settings (directly from the "builder")
/// The display scale factor
pub(crate) scale: f32,
/// Whether metrics should be quantized to pixel boundaries
pub(crate) quantize: bool,
/// The `BiDi` base level
pub(crate) base_level: u8,
/// The length of the text in the layout
pub(crate) text_len: usize,
// Output of style resolution (input to line breaking)
pub(crate) styles: Vec<Style<B>>,
pub(crate) inline_boxes: Vec<InlineBox>,
// Output of shaping (input to line breaking)
pub(crate) fonts: Vec<FontData>,
pub(crate) coords: Vec<i16>,
pub(crate) runs: Vec<RunData>,
pub(crate) items: Vec<LayoutItem>,
pub(crate) clusters: Vec<ClusterData>,
pub(crate) glyphs: Vec<Glyph>,
// Output of line breaking
/// The lines in the
pub(crate) lines: Vec<LineData>,
/// Items within each line
pub(crate) line_items: Vec<LineItemData>,
/// The width constraint that was used to line break the layout
pub(crate) layout_max_advance: f32,
/// The computed width of the layout excluding trailing whitespace
pub(crate) width: f32,
/// The computed width of the layout including trailing whitespace
pub(crate) full_width: f32,
/// The computed height of the layout
pub(crate) height: f32,
// Output of alignment
#[cfg(feature = "accesskit")]
/// Directly store the alignment if accessibility is enabled so we can
/// set the corresponding AccessKit property.
pub(crate) alignment: Option<super::Alignment>,
/// Whether the layout is aligned with [`crate::Alignment::Justify`].
pub(crate) is_aligned_justified: bool,
/// The text-indent amount in layout units.
pub(crate) indent_amount: f32,
/// Options controlling text-indent behavior (each-line, hanging).
pub(crate) indent_options: IndentOptions,
}
impl<B: Brush> Default for LayoutData<B> {
fn default() -> Self {
Self {
scale: 1.,
quantize: true,
base_level: 0,
text_len: 0,
width: 0.,
full_width: 0.,
height: 0.,
fonts: Vec::new(),
coords: Vec::new(),
styles: Vec::new(),
inline_boxes: Vec::new(),
runs: Vec::new(),
items: Vec::new(),
clusters: Vec::new(),
glyphs: Vec::new(),
lines: Vec::new(),
line_items: Vec::new(),
#[cfg(feature = "accesskit")]
alignment: None,
is_aligned_justified: false,
layout_max_advance: 0.0,
indent_amount: 0.0,
indent_options: IndentOptions::default(),
}
}
}
impl<B: Brush> LayoutData<B> {
pub(crate) fn clear(&mut self) {
self.scale = 1.;
self.quantize = true;
self.base_level = 0;
self.text_len = 0;
self.width = 0.;
self.full_width = 0.;
self.height = 0.;
self.fonts.clear();
self.coords.clear();
self.styles.clear();
self.inline_boxes.clear();
self.runs.clear();
self.items.clear();
self.clusters.clear();
self.glyphs.clear();
self.lines.clear();
self.line_items.clear();
}
/// Push an inline box to the list of items
pub(crate) fn push_inline_box(&mut self, index: usize) {
// Give the box the same bidi level as the preceding text run
// (or else default to 0 if there is not yet a text run)
let bidi_level = self.runs.last().map(|r| r.bidi_level).unwrap_or(0);
self.items.push(LayoutItem {
kind: LayoutItemKind::InlineBox,
index,
bidi_level,
});
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn push_run(
&mut self,
font: FontData,
font_size: f32,
font_attrs: fontique::Attributes,
synthesis: fontique::Synthesis,
glyph_buffer: &harfrust::GlyphBuffer,
bidi_level: u8,
style_index: u16,
word_spacing: f32,
letter_spacing: f32,
source_text: &str,
char_infos: &[(CharInfo, u16)], // From text analysis
text_range: Range<usize>, // The text range this run covers
coords: &[harfrust::NormalizedCoord],
) {
let coords_start = self.coords.len();
self.coords.extend(coords.iter().map(|c| c.to_bits()));
let coords_end = self.coords.len();
let font_index = self
.fonts
.iter()
.position(|f| *f == font)
.unwrap_or_else(|| {
let index = self.fonts.len();
self.fonts.push(font);
index
});
let metrics = {
let font = &self.fonts[font_index];
let font_ref = skrifa::FontRef::from_index(font.data.as_ref(), font.index).unwrap();
skrifa::metrics::Metrics::new(&font_ref, skrifa::prelude::Size::new(font_size), coords)
};
let units_per_em = metrics.units_per_em as f32;
let metrics = {
let (underline_offset, underline_size) = if let Some(underline) = metrics.underline {
(underline.offset, underline.thickness)
} else {
// Default values from Harfbuzz: https://github.com/harfbuzz/harfbuzz/blob/00492ec7df0038f41f78d43d477c183e4e4c506e/src/hb-ot-metrics.cc#L334
let default = units_per_em / 18.0;
(default, default)
};
let (strikethrough_offset, strikethrough_size) =
if let Some(strikeout) = metrics.strikeout {
(strikeout.offset, strikeout.thickness)
} else {
// Default values from HarfBuzz: https://github.com/harfbuzz/harfbuzz/blob/00492ec7df0038f41f78d43d477c183e4e4c506e/src/hb-ot-metrics.cc#L334-L347
(metrics.ascent / 2.0, units_per_em / 18.0)
};
// Compute line height
let style = &self.styles[style_index as usize];
let line_height = match style.line_height {
LineHeight::Absolute(value) => value,
LineHeight::FontSizeRelative(value) => value * font_size,
LineHeight::MetricsRelative(value) => {
(metrics.ascent - metrics.descent + metrics.leading) * value
}
};
RunMetrics {
ascent: metrics.ascent,
descent: -metrics.descent,
leading: metrics.leading,
underline_offset,
underline_size,
strikethrough_offset,
strikethrough_size,
line_height,
x_height: metrics.x_height,
cap_height: metrics.cap_height,
}
};
let cluster_range = self.clusters.len()..self.clusters.len();
let mut run = RunData {
font_index,
font_size,
font_attrs,
synthesis,
coords_range: coords_start..coords_end,
text_range,
bidi_level,
cluster_range,
glyph_start: self.glyphs.len(),
metrics,
word_spacing,
letter_spacing,
advance: 0.,
};
// `HarfRust` returns glyphs in visual order, so we need to process them as such while
// maintaining logical ordering of clusters.
let glyph_infos = glyph_buffer.glyph_infos();
if glyph_infos.is_empty() {
return;
}
let glyph_positions = glyph_buffer.glyph_positions();
let scale_factor = font_size / units_per_em;
let cluster_range_start = self.clusters.len();
let is_rtl = bidi_level & 1 == 1;
if !is_rtl {
run.advance = process_clusters(
Direction::Ltr,
&mut self.clusters,
&mut self.glyphs,
scale_factor,
glyph_infos,
glyph_positions,
char_infos,
source_text.char_indices(),
);
} else {
run.advance = process_clusters(
Direction::Rtl,
&mut self.clusters,
&mut self.glyphs,
scale_factor,
glyph_infos,
glyph_positions,
char_infos,
source_text.char_indices().rev(),
);
// Reverse clusters into logical order for RTL
let clusters_len = self.clusters.len();
self.clusters[cluster_range_start..clusters_len].reverse();
}
run.cluster_range = cluster_range_start..self.clusters.len();
if !run.cluster_range.is_empty() {
self.runs.push(run);
self.items.push(LayoutItem {
kind: LayoutItemKind::TextRun,
index: self.runs.len() - 1,
bidi_level,
});
}
}
pub(crate) fn finish(&mut self) {
for run in &self.runs {
let word = run.word_spacing;
let letter = run.letter_spacing;
if nearly_zero(word) && nearly_zero(letter) {
continue;
}
let clusters = &mut self.clusters[run.cluster_range.clone()];
for cluster in clusters {
let mut spacing = letter;
if !nearly_zero(word) && cluster.info.whitespace().is_space_or_nbsp() {
spacing += word;
}
if !nearly_zero(spacing) {
cluster.advance += spacing;
if cluster.glyph_len != 0xFF {
let start = run.glyph_start + cluster.glyph_offset as usize;
let end = start + cluster.glyph_len as usize;
let glyphs = &mut self.glyphs[start..end];
if let Some(last) = glyphs.last_mut() {
last.advance += spacing;
}
}
}
}
}
}
// TODO: this method does not handle mixed direction text at all.
pub(crate) fn calculate_content_widths(&self) -> ContentWidths {
fn whitespace_advance(cluster: Option<&ClusterData>) -> f32 {
cluster
.filter(|cluster| cluster.info.whitespace().is_space_or_nbsp())
.map_or(0.0, |cluster| cluster.advance)
}
let mut min_width = 0.0_f32;
let mut max_width = 0.0_f32;
let mut running_min_width = 0.0;
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;
for item in &self.items {
match item.kind {
LayoutItemKind::TextRun => {
let run = &self.runs[item.index];
let clusters = &self.clusters[run.cluster_range.clone()];
if is_rtl {
prev_cluster = clusters.first();
}
for cluster in clusters {
let boundary = cluster.info.boundary();
let style = &self.styles[cluster.style_index as usize];
let prev_text_wrap_mode = text_wrap_mode;
text_wrap_mode = style.text_wrap_mode;
if boundary == Boundary::Mandatory
|| (prev_text_wrap_mode == TextWrapMode::Wrap
&& (boundary == Boundary::Line
|| style.overflow_wrap == OverflowWrap::Anywhere))
{
let trailing_whitespace = whitespace_advance(prev_cluster);
min_width = min_width.max(running_min_width - trailing_whitespace);
running_min_width = 0.0;
if boundary == Boundary::Mandatory {
max_width = max_width.max(running_max_width - trailing_whitespace);
running_max_width = 0.0;
}
}
running_min_width += cluster.advance;
running_max_width += cluster.advance;
if !is_rtl {
prev_cluster = Some(cluster);
}
}
let trailing_whitespace = whitespace_advance(prev_cluster);
min_width = min_width.max(running_min_width - trailing_whitespace);
}
LayoutItemKind::InlineBox => {
let ibox = &self.inline_boxes[item.index];
if ibox.kind == InlineBoxKind::InFlow {
running_max_width += ibox.width;
if text_wrap_mode == TextWrapMode::Wrap {
let trailing_whitespace = whitespace_advance(prev_cluster);
min_width = min_width.max(running_min_width - trailing_whitespace);
min_width = min_width.max(ibox.width);
running_min_width = 0.0;
} else {
running_min_width += ibox.width;
}
}
prev_cluster = None;
}
}
let trailing_whitespace = whitespace_advance(prev_cluster);
max_width = max_width.max(running_max_width - trailing_whitespace);
}
let trailing_whitespace = whitespace_advance(prev_cluster);
min_width = min_width.max(running_min_width - trailing_whitespace);
ContentWidths {
min: min_width,
max: max_width,
}
}
}
/// Processes shaped glyphs from `HarfRust` and converts them into `ClusterData` and `Glyph`.
///
/// # Parameters
///
/// ## Output Parameters (mutated by this function):
/// * `clusters` - Vector where new `ClusterData` entries will be pushed.
/// * `glyphs` - Vector where new `Glyph` entries will be pushed. Note: single-glyph clusters
/// with zero offsets may be inlined directly into `ClusterData`.
///
/// ## Input Parameters:
/// * `direction` - Direction of the text.
/// * `scale_factor` - Scaling factor used to convert font units to the target size.
/// * `glyph_infos` - `HarfRust` glyph information in visual order.
/// * `glyph_positions` - `HarfRust` glyph positioning data in visual order.
/// * `char_infos` - Character information from text analysis, indexed by cluster ID.
/// * `char_indices_iter` - Iterator over (`byte_offset`, `char`) pairs from the source text.
/// Should be in logical order (forward for LTR, reverse for RTL).
fn process_clusters<I: Iterator<Item = (usize, char)>>(
direction: Direction,
clusters: &mut Vec<ClusterData>,
glyphs: &mut Vec<Glyph>,
scale_factor: f32,
glyph_infos: &[harfrust::GlyphInfo],
glyph_positions: &[harfrust::GlyphPosition],
char_infos: &[(CharInfo, u16)],
char_indices_iter: I,
) -> f32 {
let mut char_indices_iter = char_indices_iter.peekable();
let mut cluster_start_char = char_indices_iter.next().unwrap();
let mut total_glyphs: u32 = 0;
let mut cluster_glyph_offset: u32 = 0;
let start_cluster_id = glyph_infos.first().unwrap().cluster;
let mut cluster_id = start_cluster_id;
let mut char_info = char_infos[cluster_id as usize];
let mut run_advance = 0.0;
let mut cluster_advance = 0.0;
// If the current cluster might be a single-glyph, zero-offset cluster, we defer
// pushing the first glyph to `glyphs` because it might be inlined into `ClusterData`.
let mut pending_inline_glyph: Option<Glyph> = None;
// The mental model for understanding this function is best grasped by first reading
// the HarfBuzz docs on [clusters](https://harfbuzz.github.io/working-with-harfbuzz-clusters.html).
//
// `num_components` is the number of characters in the current cluster. Since source text's characters
// were inserted into `HarfRust`'s buffer using their logical indices as the cluster ID, `HarfRust` will
// assign the first character's cluster ID (in logical order) to the merged cluster because the minimum
// ID is selected for [merging](https://github.com/harfbuzz/harfrust/blob/a38025fb336230b492366740c86021bb406bcd0d/src/hb/buffer.rs#L920-L924).
//
// So, the number of components in a given cluster is dependent on `direction`.
// - In LTR, `num_components` is the difference between the next cluster and the current cluster.
// - In RTL, `num_components` is the difference between the last cluster and the current cluster.
// This is because we must compare the current cluster to its next larger ID (in other words, the next
// logical index, which is visually downstream in LTR and visually upstream in RTL).
//
// For example, consider the LTR text for "afi" where "fi" form a ligature.
// Initial cluster values: 0, 1, 2 (logical + visual order)
// `HarfRust` assignation: 0, 1, 1
// Cluster count: 2
// `num_components`: (1 - 0 =) 1, (3 - 1 =) 2
//
// Now consider the RTL text for "حداً".
// Initial cluster values: 0, 1, 2, 3 (logical, or in-memory, order)
// Reversed cluster values: 3, 2, 1, 0 (visual order - the return order of `HarfRust` for RTL)
// `HarfRust` assignation: 3, 2, 0, 0
// Cluster count: 3
// `num_components`: (4 - 3 =) 1, (3 - 2 =) 1, (2 - 0 =) 2
let num_components =
|next_cluster: u32, current_cluster: u32, last_cluster: u32| match direction {
Direction::Ltr => next_cluster - current_cluster,
Direction::Rtl => last_cluster - current_cluster,
};
let mut last_cluster_id: u32 = match direction {
Direction::Ltr => 0,
Direction::Rtl => char_infos.len() as u32,
};
for (glyph_info, glyph_pos) in glyph_infos.iter().zip(glyph_positions.iter()) {
// Flush previous cluster if we've reached a new cluster
if cluster_id != glyph_info.cluster {
run_advance += cluster_advance;
let num_components = num_components(glyph_info.cluster, cluster_id, last_cluster_id);
cluster_advance /= num_components as f32;
let is_newline = to_whitespace(cluster_start_char.1) == Whitespace::Newline;
let cluster_type = if num_components > 1 {
debug_assert!(!is_newline);
ClusterType::LigatureStart
} else if is_newline {
ClusterType::Newline
} else {
ClusterType::Regular
};
let inline_glyph_id = if matches!(cluster_type, ClusterType::Regular) {
pending_inline_glyph.take().map(|g| g.id)
} else {
// This isn't a regular cluster, so we don't inline the glyph and push
// it to `glyphs`.
if let Some(pending) = pending_inline_glyph.take() {
glyphs.push(pending);
total_glyphs += 1;
}
None
};
push_cluster(
clusters,
char_info,
cluster_start_char,
cluster_glyph_offset,
cluster_advance,
total_glyphs,
cluster_type,
inline_glyph_id,
);
cluster_glyph_offset = total_glyphs;
if num_components > 1 {
// Skip characters until we reach the current cluster
for i in 1..num_components {
cluster_start_char = char_indices_iter.next().unwrap();
if to_whitespace(cluster_start_char.1) == Whitespace::Space {
break;
}
let char_info_ = match direction {
Direction::Ltr => char_infos[(cluster_id + i) as usize],
Direction::Rtl => char_infos[(cluster_id + num_components - i) as usize],
};
push_cluster(
clusters,
char_info_,
cluster_start_char,
cluster_glyph_offset,
cluster_advance,
total_glyphs,
ClusterType::LigatureComponent,
None,
);
}
}
cluster_start_char = char_indices_iter.next().unwrap();
cluster_advance = 0.0;
last_cluster_id = cluster_id;
cluster_id = glyph_info.cluster;
char_info = char_infos[cluster_id as usize];
pending_inline_glyph = None;
}
let glyph = Glyph {
id: glyph_info.glyph_id,
style_index: char_info.1,
x: (glyph_pos.x_offset as f32) * scale_factor,
// Convert from font space (Y-up) to layout space (Y-down)
y: -(glyph_pos.y_offset as f32) * scale_factor,
advance: (glyph_pos.x_advance as f32) * scale_factor,
};
cluster_advance += glyph.advance;
// Push any pending glyph. If it was a zero-offset, single glyph cluster, it would
// have been pushed in the first `if` block.
if let Some(pending) = pending_inline_glyph.take() {
glyphs.push(pending);
total_glyphs += 1;
}
if total_glyphs == cluster_glyph_offset && glyph.x == 0.0 && glyph.y == 0.0 {
// Defer this potential zero-offset, single glyph cluster
pending_inline_glyph = Some(glyph);
} else {
glyphs.push(glyph);
total_glyphs += 1;
}
}
// Push the last cluster
{
// See comment above `num_components` for why we use `char_infos.len()` for LTR and 0 for RTL.
let next_cluster_id = match direction {
Direction::Ltr => char_infos.len() as u32,
Direction::Rtl => 0,
};
let num_components = num_components(next_cluster_id, cluster_id, last_cluster_id);
if num_components > 1 {
// This is a ligature - create ligature start + ligature components
if let Some(pending) = pending_inline_glyph.take() {
glyphs.push(pending);
total_glyphs += 1;
}
let ligature_advance = cluster_advance / num_components as f32;
push_cluster(
clusters,
char_info,
cluster_start_char,
cluster_glyph_offset,
ligature_advance,
total_glyphs,
ClusterType::LigatureStart,
None,
);
cluster_glyph_offset = total_glyphs;
// Create ligature component clusters for the remaining characters
for (i, char) in (1..).zip(char_indices_iter) {
if to_whitespace(char.1) == Whitespace::Space {
break;
}
let component_char_info = match direction {
Direction::Ltr => char_infos[(cluster_id + i) as usize],
Direction::Rtl => char_infos[(cluster_id + num_components - i) as usize],
};
push_cluster(
clusters,
component_char_info,
char,
cluster_glyph_offset,
ligature_advance,
total_glyphs,
ClusterType::LigatureComponent,
None,
);
}
} else {
let is_newline = to_whitespace(cluster_start_char.1) == Whitespace::Newline;
let cluster_type = if is_newline {
ClusterType::Newline
} else {
ClusterType::Regular
};
let mut inline_glyph_id = None;
match cluster_type {
ClusterType::Regular => {
if total_glyphs == cluster_glyph_offset {
if let Some(pending) = pending_inline_glyph.take() {
inline_glyph_id = Some(pending.id);
}
}
}
_ => {
if let Some(pending) = pending_inline_glyph.take() {
glyphs.push(pending);
total_glyphs += 1;
}
}
}
push_cluster(
clusters,
char_info,
cluster_start_char,
cluster_glyph_offset,
cluster_advance,
total_glyphs,
cluster_type,
inline_glyph_id,
);
}
}
run_advance
}
#[derive(PartialEq)]
enum Direction {
Ltr,
Rtl,
}
enum ClusterType {
LigatureStart,
LigatureComponent,
Regular,
Newline,
}
impl From<&ClusterType> for u16 {
fn from(cluster_type: &ClusterType) -> Self {
match cluster_type {
ClusterType::LigatureStart => ClusterData::LIGATURE_START,
ClusterType::LigatureComponent => ClusterData::LIGATURE_COMPONENT,
ClusterType::Regular | ClusterType::Newline => 0, // No special flags
}
}
}
fn push_cluster(
clusters: &mut Vec<ClusterData>,
char_info: (CharInfo, u16),
cluster_start_char: (usize, char),
glyph_offset: u32,
advance: f32,
total_glyphs: u32,
cluster_type: ClusterType,
inline_glyph_id: Option<u32>,
) {
let glyph_len = (total_glyphs - glyph_offset) as u8;
let (final_glyph_len, final_glyph_offset, final_advance) = match cluster_type {
ClusterType::LigatureComponent => {
// Ligature components have no glyphs, only advance.
debug_assert_eq!(glyph_len, 0);
(0_u8, 0_u32, advance)
}
ClusterType::Newline => {
// Newline clusters are stripped of their glyph contribution.
debug_assert_eq!(glyph_len, 1);
(0_u8, 0_u32, 0.0)
}
_ if inline_glyph_id.is_some() => {
// Inline glyphs are stored inline within `ClusterData`
debug_assert_eq!(glyph_len, 0);
(0xFF_u8, inline_glyph_id.unwrap(), advance)
}
ClusterType::Regular | ClusterType::LigatureStart => {
// Regular and ligature start clusters maintain their glyphs and advance.
debug_assert_ne!(glyph_len, 0);
(glyph_len, glyph_offset, advance)
}
};
clusters.push(ClusterData {
info: ClusterInfo::new(char_info.0.boundary, cluster_start_char.1),
flags: (&cluster_type).into(),
style_index: char_info.1,
glyph_len: final_glyph_len,
text_len: cluster_start_char.1.len_utf8() as u8,
glyph_offset: final_glyph_offset,
text_offset: cluster_start_char.0 as u16,
advance: final_advance,
});
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
/// Glyph with an offset and advance.
#[derive(Copy, Clone, Default, Debug, PartialEq)]
pub struct Glyph {
pub id: u32,
pub style_index: u16,
pub x: f32,
pub y: f32,
pub advance: f32,
}
impl Glyph {
/// Returns the index into the layout style collection.
pub fn style_index(&self) -> usize {
self.style_index as usize
}
}
+234
View File
@@ -0,0 +1,234 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::InlineBox;
use crate::layout::alignment::align;
use crate::layout::alignment::unjustify;
use crate::layout::data::LayoutData;
use crate::style::Brush;
use core::cmp::Ordering;
use core::fmt;
use crate::IndentOptions;
use crate::layout::{
ContentWidths, Style, alignment::Alignment, alignment::AlignmentOptions, line::Line,
line_break::BreakLines,
};
/// Text layout.
///
/// The [`Debug`] implementation prints a compact summary by default.
/// The alternate form (`{:#?}`) formats the full underlying data.
///
/// [`Debug`]: core::fmt::Debug
#[derive(Clone)]
pub struct Layout<B: Brush> {
pub(crate) data: LayoutData<B>,
}
impl<B: Brush> fmt::Debug for Layout<B> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if f.alternate() {
f.debug_struct("Layout").field("data", &self.data).finish()
} else {
f.debug_struct("Layout")
.field("text_len", &self.data.text_len)
.field("width", &self.data.width)
.field("height", &self.data.height)
.field("lines", &self.data.lines.len())
.field("runs", &self.data.runs.len())
.field("styles", &self.data.styles.len())
.finish_non_exhaustive()
}
}
}
impl<B: Brush> Layout<B> {
/// Creates an empty layout.
pub fn new() -> Self {
Self::default()
}
/// Returns the scale factor provided when creating the layout.
pub fn scale(&self) -> f32 {
self.data.scale
}
/// Returns the style collection for the layout.
pub fn styles(&self) -> &[Style<B>] {
&self.data.styles
}
/// The `max_advance` that was used to line break the `Layout`
pub fn layout_max_advance(&self) -> f32 {
self.data.layout_max_advance
}
/// Returns the computed width of the layout excluding the width of trailing whitespace.
pub fn width(&self) -> f32 {
self.data.width
}
/// Returns the computed width of the layout including the width of trailing whitespace.
pub fn full_width(&self) -> f32 {
self.data.full_width
}
/// Calculates the lower and upper bounds on the width of the layout. These
/// are recalculated every time this method is called.
///
/// This method currently may not return the correct results for
/// mixed-direction text.
pub fn calculate_content_widths(&self) -> ContentWidths {
self.data.calculate_content_widths()
}
/// Returns the height of the layout.
pub fn height(&self) -> f32 {
self.data.height
}
/// Returns the number of lines in the layout.
pub fn len(&self) -> usize {
self.data.lines.len()
}
/// Returns `true` if the layout is empty.
pub fn is_empty(&self) -> bool {
self.data.lines.is_empty()
}
/// Returns the line at the specified index.
///
/// Returns `None` if the index is out of bounds, i.e. if it's
/// not less than [`self.len()`](Self::len).
pub fn get(&self, index: usize) -> Option<Line<'_, B>> {
Some(Line {
index: index as u32,
layout: self,
data: self.data.lines.get(index)?,
})
}
/// 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
}
pub fn inline_boxes(&self) -> &[InlineBox] {
&self.data.inline_boxes
}
pub fn inline_boxes_mut(&mut self) -> &mut [InlineBox] {
&mut self.data.inline_boxes
}
/// Returns an iterator over the lines in the layout.
pub fn lines(
&self,
) -> impl ExactSizeIterator<Item = Line<'_, B>> + DoubleEndedIterator + '_ + Clone {
self.data
.lines
.iter()
.enumerate()
.map(move |(index, data)| Line {
index: index as u32,
layout: self,
data,
})
}
/// Sets the text-indent for the layout.
///
/// The indent is applied as a margin on the start edge of indented lines, reducing the
/// available width for line breaking and offsetting content during alignment. Negative
/// values cause the line to protrude beyond the start edge.
///
/// This must be called before [`Layout::break_all_lines`] or [`Layout::break_lines`],
/// and before [`Layout::align`].
pub fn set_text_indent(&mut self, amount: f32, options: IndentOptions) {
self.data.indent_amount = amount;
self.data.indent_options = options;
}
/// Returns line breaker to compute lines for the layout.
pub fn break_lines(&mut self) -> BreakLines<'_, B> {
unjustify(&mut self.data);
BreakLines::new(self)
}
/// Breaks all lines with the specified maximum advance.
pub fn break_all_lines(&mut self, max_advance: Option<f32>) {
self.break_lines()
.break_remaining(max_advance.unwrap_or(f32::MAX));
}
/// Apply alignment to the layout.
///
/// You must perform line breaking prior to aligning, through [`Layout::break_lines`] or
/// [`Layout::break_all_lines`].
///
/// If a finite `max_advance` is supplied to `Layout::break_all_lines` then that width will be applied
/// relative to that width. Otherwise alignment will be applied relative to the width of the
/// longest line as computed by line breaking.
///
/// If line-specific `offset` and `max_advance` are set using the advanced methods on the `BreakLines`
/// struct then each line will be aligned individually within its line box.
pub fn align(&mut self, alignment: Alignment, options: AlignmentOptions) {
unjustify(&mut self.data);
align(&mut self.data, alignment, options);
}
/// Returns the index and `Line` object for the line containing the
/// given byte `index` in the source text.
pub(crate) fn line_for_byte_index(&self, index: usize) -> Option<(usize, Line<'_, B>)> {
let line_index = self
.data
.lines
.binary_search_by(|line| {
if index < line.text_range.start {
Ordering::Greater
} else if index >= line.text_range.end {
Ordering::Less
} else {
Ordering::Equal
}
})
.ok()?;
Some((line_index, self.get(line_index)?))
}
/// Returns the index and `Line` object for the line containing the
/// given `offset`.
///
/// The offset is specified in the direction orthogonal to line direction.
/// For horizontal text, this is a vertical or y offset. If the offset is
/// on a line boundary, it is considered to be contained by the later line.
pub(crate) fn line_for_offset(&self, offset: f32) -> Option<(usize, Line<'_, B>)> {
if offset < 0.0 {
return Some((0, self.get(0)?));
}
let maybe_line_index = self.data.lines.binary_search_by(|line| {
if offset < line.metrics.block_min_coord {
Ordering::Greater
} else if offset >= line.metrics.block_max_coord {
Ordering::Less
} else {
Ordering::Equal
}
});
let line_index = match maybe_line_index {
Ok(index) => index,
Err(index) => index.saturating_sub(1),
};
Some((line_index, self.get(line_index)?))
}
}
impl<B: Brush> Default for Layout<B> {
fn default() -> Self {
Self {
data: LayoutData::default(),
}
}
}
+318
View File
@@ -0,0 +1,318 @@
// 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::glyph::Glyph;
use crate::layout::layout::Layout;
use crate::layout::run::Run;
use crate::style::Brush;
use crate::{InlineBox, InlineBoxKind};
use core::ops::Range;
/// Line in a text layout.
#[derive(Copy, Clone)]
pub struct Line<'a, B: Brush> {
pub(crate) layout: &'a Layout<B>,
pub(crate) index: u32,
pub(crate) data: &'a LineData,
}
impl<'a, B: Brush> Line<'a, B> {
/// Returns the metrics for the line.
pub fn metrics(&self) -> &LineMetrics {
&self.data.metrics
}
pub fn break_reason(&self) -> BreakReason {
self.data.break_reason
}
/// Returns the range of text for the line.
pub fn text_range(&self) -> Range<usize> {
self.data.text_range.clone()
}
/// Returns the number of items in the line.
pub fn len(&self) -> usize {
self.data.item_range.len()
}
/// Returns `true` if the line is empty.
pub fn is_empty(&self) -> bool {
self.data.item_range.is_empty()
}
/// Returns the line item at the specified index.
pub(crate) fn item(&self, index: usize) -> Option<LineItem<'a, B>> {
let original_index = index;
let index = self.data.item_range.start + index;
if index >= self.data.item_range.end {
return None;
}
let item = self.layout.data.line_items.get(index)?;
Some(match item.kind {
LayoutItemKind::TextRun => LineItem::Run(Run {
layout: self.layout,
line_index: self.index,
index: original_index as u32,
data: self.layout.data.runs.get(item.index)?,
line_data: Some(item),
}),
LayoutItemKind::InlineBox => {
LineItem::InlineBox(self.layout.data.inline_boxes.get(item.index)?)
}
})
}
/// Returns an iterator over the runs for the line.
pub fn runs(&self) -> impl Iterator<Item = Run<'a, B>> + 'a + Clone {
self.items_nonpositioned().filter_map(|item| item.run())
}
/// Returns an iterator over the non-glyph runs and inline boxes for the line.
pub(crate) fn items_nonpositioned(&self) -> impl Iterator<Item = LineItem<'a, B>> + Clone {
let copy = self.clone();
let line_items = &copy.layout.data.line_items[self.data.item_range.clone()];
line_items
.iter()
.enumerate()
.map(move |(item_index, line_data)| match line_data.kind {
LayoutItemKind::TextRun => LineItem::Run(Run {
layout: copy.layout,
line_index: copy.index,
index: item_index as u32,
data: &copy.layout.data.runs[line_data.index],
line_data: Some(line_data),
}),
LayoutItemKind::InlineBox => {
LineItem::InlineBox(&copy.layout.data.inline_boxes[line_data.index])
}
})
}
/// Returns an iterator over the glyph runs for the line.
pub fn items(&self) -> impl Iterator<Item = PositionedLayoutItem<'a, B>> + 'a + Clone {
GlyphRunIter {
line: self.clone(),
item_index: 0,
glyph_start: 0,
offset: 0.,
}
}
}
/// Metrics information for a line.
#[derive(Copy, Clone, Default, Debug, PartialEq)]
pub struct LineMetrics {
/// Typographic ascent.
pub ascent: f32,
/// Typographic descent.
pub descent: f32,
/// Typographic leading.
pub leading: f32,
/// The absolute line height (in layout units).
pub line_height: f32,
/// Offset to the baseline.
pub baseline: f32,
/// Offset for alignment.
pub offset: f32,
/// Full advance of the line, including trailing whitespace.
pub advance: f32,
/// Advance of trailing whitespace.
pub trailing_whitespace: f32,
/// Minimum coordinate in the line direction.
///
/// For horizontal text, this would be the left of the line.
pub inline_min_coord: f32,
/// Maximum coordinate in the line direction.
///
/// For horizontal text, this would be the right of the line.
pub inline_max_coord: f32,
/// Minimum coordinate in the direction orthogonal to line
/// direction.
///
/// For horizontal text, this would be the top of the line.
pub block_min_coord: f32,
/// Maximum coordinate in the direction orthogonal to line
/// direction.
///
/// For horizontal text, this would be the bottom of the line.
pub block_max_coord: f32,
}
impl LineMetrics {
/// Returns the size of the line
pub fn size(&self) -> f32 {
self.line_height
}
}
/// A line item and its corresponding data (a run or inline box). Unlike a
/// [`PositionedLayoutItem`], runs are not guaranteed to be split by style.
pub(crate) enum LineItem<'a, B: Brush> {
Run(Run<'a, B>),
InlineBox(&'a InlineBox),
}
impl<'a, B: Brush> LineItem<'a, B> {
pub(crate) fn run(self) -> Option<Run<'a, B>> {
match self {
LineItem::Run(run) => Some(run),
_ => None,
}
}
}
/// The computed result of an item (glyph run or inline box) within a layout
#[derive(Clone)]
pub enum PositionedLayoutItem<'a, B: Brush> {
GlyphRun(GlyphRun<'a, B>),
InlineBox(PositionedInlineBox),
}
/// The computed position of an inline box within a layout
#[derive(Debug, Clone)]
pub struct PositionedInlineBox {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub id: u64,
pub kind: InlineBoxKind,
}
/// Sequence of fully positioned glyphs with the same style.
#[derive(Clone)]
pub struct GlyphRun<'a, B: Brush> {
run: Run<'a, B>,
style: &'a Style<B>,
glyph_start: usize,
glyph_count: usize,
offset: f32,
baseline: f32,
advance: f32,
}
impl<'a, B: Brush> GlyphRun<'a, B> {
/// Returns the underlying run.
pub fn run(&self) -> &Run<'a, B> {
&self.run
}
/// Returns the associated style.
pub fn style(&self) -> &Style<B> {
self.style
}
/// Returns the offset to the baseline.
pub fn baseline(&self) -> f32 {
self.baseline
}
/// Returns the offset to the first glyph along the baseline.
pub fn offset(&self) -> f32 {
self.offset
}
/// Returns the total advance of the run.
pub fn advance(&self) -> f32 {
self.advance
}
/// Returns an iterator over the glyphs in the run.
pub fn glyphs(&'a self) -> impl Iterator<Item = Glyph> + 'a + Clone {
self.run
.visual_clusters()
.flat_map(|cluster| cluster.glyphs())
.skip(self.glyph_start)
.take(self.glyph_count)
}
/// Returns an iterator over the fully positioned glyphs in the run.
pub fn positioned_glyphs(&'a self) -> impl Iterator<Item = Glyph> + 'a + Clone {
let mut offset = self.offset;
let baseline = self.baseline;
self.glyphs().map(move |mut g| {
g.x += offset;
g.y += baseline;
offset += g.advance;
g
})
}
}
#[derive(Clone)]
struct GlyphRunIter<'a, B: Brush> {
line: Line<'a, B>,
item_index: usize,
glyph_start: usize,
offset: f32,
}
impl<'a, B: Brush> Iterator for GlyphRunIter<'a, B> {
type Item = PositionedLayoutItem<'a, B>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let item = self.line.item(self.item_index)?;
match item {
LineItem::InlineBox(inline_box) => {
let x = self.offset
+ self.line.data.metrics.inline_min_coord
+ self.line.data.metrics.offset;
self.item_index += 1;
self.glyph_start = 0;
if inline_box.kind == InlineBoxKind::InFlow {
self.offset += inline_box.width;
}
return Some(PositionedLayoutItem::InlineBox(PositionedInlineBox {
x,
y: self.line.data.metrics.baseline - inline_box.height,
width: inline_box.width,
height: inline_box.height,
id: inline_box.id,
kind: inline_box.kind,
}));
}
LineItem::Run(run) => {
let mut iter = run
.visual_clusters()
.flat_map(|c| c.glyphs())
.skip(self.glyph_start);
if let Some(first) = iter.next() {
let mut advance = first.advance;
let style_index = first.style_index();
let mut glyph_count = 1;
for glyph in iter.take_while(|g| g.style_index() == style_index) {
glyph_count += 1;
advance += glyph.advance;
}
let style = run.layout.data.styles.get(style_index)?;
let glyph_start = self.glyph_start;
self.glyph_start += glyph_count;
let offset = self.offset;
self.offset += advance;
return Some(PositionedLayoutItem::GlyphRun(GlyphRun {
run,
style,
glyph_start,
glyph_count,
offset: offset
+ self.line.data.metrics.inline_min_coord
+ self.line.data.metrics.offset,
baseline: self.line.data.metrics.baseline,
advance,
}));
}
self.item_index += 1;
self.glyph_start = 0;
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Layout types.
#[cfg(feature = "accesskit")]
mod accessibility;
mod alignment;
mod cluster;
mod glyph;
mod line;
mod line_break;
mod run;
// TODO - Add to allowed lint set?
#[expect(
clippy::module_inception,
reason = "Private inner module for code organisation"
)]
mod layout;
pub(crate) mod data;
#[cfg(feature = "accesskit")]
pub use accessibility::LayoutAccessibility;
pub use alignment::{Alignment, AlignmentOptions};
pub use cluster::{Affinity, Cluster, ClusterPath, ClusterSide};
pub use data::BreakReason;
pub use glyph::Glyph;
pub use layout::Layout;
pub use line::{GlyphRun, Line, LineMetrics, PositionedInlineBox, PositionedLayoutItem};
pub use line_break::{
BoxBreakData, BreakLines, BreakerState, LineBreakData, MaxHeightBreakData, YieldData,
};
pub use run::{Run, RunMetrics};
pub(crate) use data::{LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData};
pub(crate) use line::LineItem;
// TODO - Deprecation not yet active to ease internal code migration.
#[deprecated(since = "TBD", note = "Access from the `editing` module instead.")]
pub use crate::editing::{Cursor, Selection};
// TODO - Move the following to `style` module and submodules.
use crate::style::Brush;
use crate::{LineHeight, OverflowWrap, TextWrapMode};
#[allow(clippy::partial_pub_fields)]
/// Style properties.
#[derive(Clone, Debug, PartialEq)]
pub struct Style<B: Brush> {
/// Brush for drawing glyphs.
pub brush: B,
/// Underline decoration.
pub underline: Option<Decoration<B>>,
/// Strikethrough decoration.
pub strikethrough: Option<Decoration<B>>,
/// Partially resolved line height, either in in layout units or dependent on metrics
pub(crate) line_height: LineHeight,
/// Per-cluster overflow-wrap setting
pub(crate) overflow_wrap: OverflowWrap,
/// Per-cluster text-wrap-mode setting
pub(crate) text_wrap_mode: TextWrapMode,
#[cfg(feature = "accesskit")]
/// Locale if any, so we can set the corresponding AccessKit property
pub(crate) locale: Option<fontique::Language>,
}
/// Underline or strikethrough decoration.
#[derive(Clone, Debug, PartialEq)]
pub struct Decoration<B: Brush> {
/// Brush used to draw the decoration.
pub brush: B,
/// Offset of the decoration from the baseline. If `None`, use the metrics
/// of the containing run.
pub offset: Option<f32>,
/// Thickness of the decoration. If `None`, use the metrics of the
/// containing run.
pub size: Option<f32>,
}
/// Lower and upper bounds on layout width based on its contents.
#[derive(Copy, Clone, Debug)]
pub struct ContentWidths {
/// The minimum content width. This is the width of the layout if _all_ soft line-breaking
/// opportunities are taken.
pub min: f32,
/// The maximum content width. This is the width of the layout if _no_ soft line-breaking
/// opportunities are taken.
pub max: f32,
}
/// Options controlling text-indent behavior, corresponding to CSS `text-indent` keywords.
#[derive(Copy, Clone, Default, PartialEq, Debug)]
pub struct IndentOptions {
/// If `true`, indent also applies after every hard line break, not just to the first line.
/// Corresponds to the CSS `each-line` keyword. Defaults to `false`.
pub each_line: bool,
/// If `true`, inverts which lines are indented: continuation lines are indented
/// instead of the first line(s). Corresponds to the CSS `hanging` keyword. Defaults to `false`.
pub hanging: bool,
}
+245
View File
@@ -0,0 +1,245 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::FontData;
use crate::layout::cluster::{Cluster, ClusterPath};
use crate::layout::data::{LineItemData, RunData};
use crate::layout::layout::Layout;
use crate::style::Brush;
use core::ops::Range;
use fontique::Synthesis;
/// Sequence of clusters with a single font and style.
#[derive(Copy, Clone)]
pub struct Run<'a, B: Brush> {
pub(crate) layout: &'a Layout<B>,
pub(crate) line_index: u32,
pub(crate) index: u32,
pub(crate) data: &'a RunData,
pub(crate) line_data: Option<&'a LineItemData>,
}
impl<'a, B: Brush> Run<'a, B> {
pub(crate) fn new(
layout: &'a Layout<B>,
line_index: u32,
index: u32,
data: &'a RunData,
line_data: Option<&'a LineItemData>,
) -> Self {
Self {
layout,
line_index,
index,
data,
line_data,
}
}
/// Returns the index of the run within the line.
pub fn index(&self) -> usize {
self.index as usize
}
/// Returns the font for the run.
pub fn font(&self) -> &FontData {
self.layout.data.fonts.get(self.data.font_index).unwrap()
}
/// Returns the font size for the run.
pub fn font_size(&self) -> f32 {
self.data.font_size
}
/// Returns the font attributes for the run.
pub fn font_attrs(&self) -> &fontique::Attributes {
&self.data.font_attrs
}
/// Returns the synthesis suggestions for the font associated with the run.
pub fn synthesis(&self) -> Synthesis {
self.data.synthesis
}
/// Returns the normalized variation coordinates for the font associated
/// with the run.
pub fn normalized_coords(&self) -> &[i16] {
self.layout
.data
.coords
.get(self.data.coords_range.clone())
.unwrap_or(&[])
}
/// Returns metrics for the run.
pub fn metrics(&self) -> &RunMetrics {
&self.data.metrics
}
/// Returns the advance for the run.
pub fn advance(&self) -> f32 {
self.line_data
.map(|d| d.advance)
.unwrap_or(self.data.advance)
}
/// Returns the original text range for the run.
pub fn text_range(&self) -> Range<usize> {
self.line_data
.map(|d| &d.text_range)
.unwrap_or(&self.data.text_range)
.clone()
}
/// Returns `true` if the run has right-to-left directionality.
pub fn is_rtl(&self) -> bool {
self.data.bidi_level & 1 != 0
}
/// Returns the cluster range for the run.
pub fn cluster_range(&self) -> Range<usize> {
self.line_data
.map(|d| &d.cluster_range)
.unwrap_or(&self.data.cluster_range)
.clone()
}
/// Returns the number of clusters in the run.
pub fn len(&self) -> usize {
self.cluster_range().len()
}
/// Returns `true` if the run is empty.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Returns the cluster at the specified index.
pub fn get(&self, index: usize) -> Option<Cluster<'a, B>> {
let range = self
.line_data
.map(|d| &d.cluster_range)
.unwrap_or(&self.data.cluster_range);
let original_index = index;
let index = range.start + index;
Some(Cluster {
path: ClusterPath::new(self.line_index, self.index, original_index as u32),
run: self.clone(),
data: self.layout.data.clusters.get(index)?,
})
}
/// Returns an iterator over the clusters in logical order.
pub fn clusters(&'a self) -> impl Iterator<Item = Cluster<'a, B>> + 'a + Clone {
let range = self.cluster_range();
Clusters {
run: self,
range,
rev: false,
}
}
/// Returns the visual cluster index for the specified logical cluster index.
pub fn logical_to_visual(&self, logical_index: usize) -> Option<usize> {
let num_clusters = self.len();
if logical_index >= num_clusters {
return None;
}
let visual_index = if self.is_rtl() {
num_clusters - 1 - logical_index
} else {
logical_index
};
Some(visual_index)
}
/// Returns the logical cluster index for the specified visual cluster index.
pub fn visual_to_logical(&self, visual_index: usize) -> Option<usize> {
let num_clusters = self.len();
if visual_index >= num_clusters {
return None;
}
let logical_index = if self.is_rtl() {
num_clusters - 1 - visual_index
} else {
visual_index
};
Some(logical_index)
}
/// Returns an iterator over the clusters in visual order.
pub fn visual_clusters(&'a self) -> impl Iterator<Item = Cluster<'a, B>> + 'a + Clone {
let range = self.cluster_range();
Clusters {
run: self,
range,
rev: self.is_rtl(),
}
}
}
struct Clusters<'a, B: Brush> {
run: &'a Run<'a, B>,
range: Range<usize>,
rev: bool,
}
impl<B: Brush> Clone for Clusters<'_, B> {
fn clone(&self) -> Self {
Self {
run: self.run,
range: self.range.clone(),
rev: self.rev,
}
}
}
impl<'a, B: Brush> Iterator for Clusters<'a, B> {
type Item = Cluster<'a, B>;
fn next(&mut self) -> Option<Self::Item> {
let index = if self.rev {
self.range.next_back()?
} else {
self.range.next()?
};
Some(Cluster {
path: ClusterPath::new(
self.run.line_index,
self.run.index,
(index - self.run.cluster_range().start) as u32,
),
run: self.run.clone(),
data: self.run.layout.data.clusters.get(index)?,
})
}
}
/// Metrics information for a run.
#[derive(Copy, Clone, Default, Debug, PartialEq)]
pub struct RunMetrics {
/// Typographic ascent.
pub ascent: f32,
/// Typographic descent.
pub descent: f32,
/// Typographic leading.
pub leading: f32,
/// Offset of the top of underline decoration from the baseline.
pub underline_offset: f32,
/// Thickness of the underline decoration.
pub underline_size: f32,
/// Offset of the top of strikethrough decoration from the baseline.
pub strikethrough_offset: f32,
/// Thickness of the strikethrough decoration.
pub strikethrough_size: f32,
/// The line height
pub line_height: f32,
/// Distance from the baseline to the top of short lowercase letters.
pub x_height: Option<f32>,
/// Distance from the baseline to the top of capital letters.
pub cap_height: Option<f32>,
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Parley is a library for rich text layout.
//!
//! Some key types are:
//! - [`FontContext`] and [`LayoutContext`] are resources which should be shared globally (or at coarse-grained boundaries).
//! - [`FontContext`] is database of fonts.
//! - [`LayoutContext`] is scratch space that allows for reuse of allocations between layouts.
//! - Builders for creating a [`Layout`]:
//! - [`RangedBuilder`]: styles specified as a flat `Vec` of property spans
//! - [`TreeBuilder`]: styles specified as a tree of spans
//! - [`StyleRunBuilder`]: styles specified as a shared style table plus non-overlapping indexed runs
//!
//! They are constructed using [`LayoutContext::ranged_builder`], [`LayoutContext::tree_builder`],
//! and [`LayoutContext::style_run_builder`].
//! - [`Layout`] which represents styled paragraph(s) of text and can perform shaping, line-breaking, bidi-reordering, and alignment of that text.
//!
//! `Layout` supports re-linebreaking and re-aligning many times (in case the width at which wrapping should occur changes). But if the text content or
//! the styles applied to that content change then a new `Layout` must be created using a new
//! `RangedBuilder`, `TreeBuilder`, or `StyleRunBuilder`.
//!
//! ## Usage Example
//!
//! See the [examples](https://github.com/linebender/parley/tree/main/examples) directory for more complete usage examples that include rendering.
//!
//! ```rust
//! use parley::{
//! Alignment, AlignmentOptions, FontContext, FontWeight, InlineBox, InlineBoxKind, Layout,
//! LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
//! };
//!
//! // Create a FontContext (font database) and LayoutContext (scratch space).
//! // These are both intended to be constructed rarely (perhaps even once per app):
//! let mut font_cx = FontContext::new();
//! let mut layout_cx = LayoutContext::new();
//!
//! // Create a `RangedBuilder` or a `TreeBuilder`, which are used to construct a `Layout`.
//! const DISPLAY_SCALE : f32 = 1.0;
//! const TEXT : &str = "Lorem Ipsum...";
//! let mut builder = layout_cx.ranged_builder(&mut font_cx, &TEXT, DISPLAY_SCALE, true);
//!
//! // Set default styles that apply to the entire layout
//! builder.push_default(StyleProperty::FontSize(16.0));
//!
//! // Set a style that applies to the first 4 characters
//! builder.push(StyleProperty::FontWeight(FontWeight::new(600.0)), 0..4);
//!
//! // Add a box to be laid out inline with the text
//! builder.push_inline_box(InlineBox { id: 0, kind: InlineBoxKind::InFlow, index: 5, width: 50.0, height: 50.0 });
//!
//! // Build the builder into a Layout
//! let mut layout: Layout<()> = builder.build(&TEXT);
//!
//! // Run line-breaking and alignment on the Layout
//! const MAX_WIDTH : Option<f32> = Some(100.0);
//! layout.break_all_lines(MAX_WIDTH);
//! layout.align(Alignment::Start, AlignmentOptions::default());
//!
//! // Inspect computed layout (see examples for more details)
//! let width = layout.width();
//! let height = layout.height();
//! for line in layout.lines() {
//! for item in line.items() {
//! match item {
//! PositionedLayoutItem::GlyphRun(glyph_run) => {
//! // Render the glyph run
//! }
//! PositionedLayoutItem::InlineBox(inline_box) => {
//! // Render the inline box
//! }
//! };
//! }
//! }
//! ```
// LINEBENDER LINT SET - lib.rs - v4
// See https://linebender.org/wiki/canonical-lints/
// These lints shouldn't apply to examples or tests.
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
// These lints shouldn't apply to examples.
#![warn(clippy::print_stdout, clippy::print_stderr)]
// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit.
#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))]
// END LINEBENDER LINT SET
#![cfg_attr(docsrs, feature(doc_cfg))]
#![no_std]
#![allow(missing_docs, reason = "We have many as-yet undocumented items.")]
#![expect(
missing_debug_implementations,
clippy::allow_attributes_without_reason,
clippy::cast_possible_truncation,
clippy::missing_assert_message,
reason = "Deferred"
)]
#![expect(
single_use_lifetimes,
reason = "False positive: https://github.com/rust-lang/rust/issues/129255"
)]
#[cfg(not(any(feature = "std", feature = "libm")))]
compile_error!("parley requires either the `std` or `libm` feature to be enabled");
extern crate alloc;
#[cfg(feature = "std")]
extern crate std;
pub use fontique;
mod analysis;
mod bidi;
mod break_overrides;
mod builder;
mod context;
mod convert;
mod font;
mod inline_box;
mod lru_cache;
mod resolve;
mod shape;
mod util;
pub mod editing;
pub mod layout;
pub mod setting;
pub mod style;
#[cfg(test)]
mod tests;
pub use linebender_resource_handle::FontData;
pub use util::BoundingBox;
pub use break_overrides::{
AsciiLineBreakTable, AsciiLineBreakTableBuilder, CHROMIUM_LINE_BREAK_OVERRIDE,
LineBreakContext, LineBreakOverrideFn,
};
pub use builder::{RangedBuilder, StyleRunBuilder, TreeBuilder};
pub use context::LayoutContext;
pub use font::FontContext;
pub use inline_box::{InlineBox, InlineBoxKind};
#[doc(inline)]
pub use layout::Layout;
pub use editing::*;
pub use layout::*;
pub use style::*;
#[deprecated(
note = "Old name for this type, use `parley::FontData` instead.",
since = "0.6.0"
)]
pub type Font = FontData;
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use alloc::vec::Vec;
use hashbrown::Equivalent;
/// An entry in the cache.
pub(crate) struct Entry<ID, T> {
pub epoch: u64,
pub id: ID,
pub data: T,
}
/// A least-recently-used cache. This cache uses a linear scan of its entries
/// to find a given entry - it is optimised for a low number of entries. Preferably
/// keep `max_entries` low - in the order of tens.
pub(crate) struct LruCache<ID, T> {
entries: Vec<Entry<ID, T>>,
epoch: u64,
max_entries: usize,
}
impl<ID, T> LruCache<ID, T> {
pub(crate) fn new(max_entries: usize) -> Self {
Self {
entries: Vec::default(),
epoch: 0,
max_entries,
}
}
/// Returns a reference to the entry with the given ID. If the entry is not
/// found, it is created and returned using `make_data`.
///
/// The lookup key must be `Equivalent` to ID for lookups and convertible `Into<ID>`
/// for creating new entries.
pub(crate) fn entry<K>(&mut self, id: K, make_data: impl FnOnce() -> T) -> &T
where
K: Equivalent<ID> + Into<ID>,
{
let index = self.find_entry(id, make_data);
self.epoch += 1;
let entry = &mut self.entries[index];
entry.epoch = self.epoch;
&entry.data
}
fn find_entry<K>(&mut self, id: K, make_data: impl FnOnce() -> T) -> usize
where
K: Equivalent<ID> + Into<ID>,
{
let epoch = self.epoch;
let mut lowest_serial = epoch;
let mut lowest_index = 0;
for (i, entry) in self.entries.iter().enumerate() {
if id.equivalent(&entry.id) {
return i;
}
if entry.epoch < lowest_serial {
lowest_serial = entry.epoch;
lowest_index = i;
}
}
if self.entries.len() < self.max_entries {
lowest_index = self.entries.len();
self.entries.push(Entry {
epoch,
id: id.into(),
data: make_data(),
});
} else {
let entry = &mut self.entries[lowest_index];
entry.epoch = epoch;
entry.id = id.into();
entry.data = make_data();
}
lowest_index
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::string::{String, ToString};
#[derive(Debug, Clone, PartialEq)]
struct TestId(String);
struct TestLookupKey<'a>(&'a str);
impl<'a> Equivalent<TestId> for TestLookupKey<'a> {
fn equivalent(&self, key: &TestId) -> bool {
self.0 == key.0.as_str()
}
}
impl<'a> From<TestLookupKey<'a>> for TestId {
fn from(key: TestLookupKey<'a>) -> Self {
Self(key.0.to_string())
}
}
impl Equivalent<Self> for TestId {
fn equivalent(&self, key: &Self) -> bool {
self.0 == key.0
}
}
#[test]
fn test_retrieve_existing_entry() {
let mut cache = LruCache::new(3);
// Insert an entry
let value1 = cache.entry(TestLookupKey("key1"), || 42);
assert_eq!(*value1, 42);
// Retrieve the same entry - make_data should not be called
let value2 = cache.entry(TestLookupKey("key1"), || {
panic!("Should not create new data")
});
assert_eq!(*value2, 42);
assert_eq!(cache.entries.len(), 1);
}
#[test]
fn test_multiple_entries() {
let mut cache = LruCache::new(3);
let value1 = cache.entry(TestLookupKey("key1"), || 1);
assert_eq!(*value1, 1);
let value2 = cache.entry(TestLookupKey("key2"), || 2);
assert_eq!(*value2, 2);
let value3 = cache.entry(TestLookupKey("key3"), || 3);
assert_eq!(*value3, 3);
assert_eq!(cache.entries.len(), 3);
assert_eq!(cache.epoch, 3);
}
#[test]
fn test_lru_eviction() {
let mut cache = LruCache::new(3);
// Add three entries
cache.entry(TestLookupKey("key1"), || 1);
cache.entry(TestLookupKey("key2"), || 2);
cache.entry(TestLookupKey("key3"), || 3);
// Access key1 to update its epoch
cache.entry(TestLookupKey("key1"), || panic!("Should not create"));
// Add key4 - should evict key2 (oldest untouched)
cache.entry(TestLookupKey("key4"), || 4);
// Verify key1 is still present
let value1 = cache.entry(TestLookupKey("key1"), || {
panic!("key1 should still be present")
});
assert_eq!(*value1, 1);
// Verify key2 was evicted
let mut was_created = false;
cache.entry(TestLookupKey("key2"), || {
was_created = true;
20
});
assert!(was_created, "key2 should have been evicted");
}
#[test]
fn test_lru_eviction_after_multiple_hits() {
let mut cache = LruCache::new(3);
cache.entry(TestLookupKey("key1"), || 1);
cache.entry(TestLookupKey("key2"), || 2);
cache.entry(TestLookupKey("key3"), || 3);
// Hit all three in order: key3 first (making it LRU among hits),
// then key1, then key2 (most recently used).
cache.entry(TestLookupKey("key3"), || panic!("Should not create"));
cache.entry(TestLookupKey("key1"), || panic!("Should not create"));
cache.entry(TestLookupKey("key2"), || panic!("Should not create"));
// Insert key4 — should evict key3 (least recently accessed)
cache.entry(TestLookupKey("key4"), || 4);
// key1 and key2 should still be present with original values
// (check these first since verifying key3 eviction will trigger another eviction)
let v1 = cache.entry(TestLookupKey("key1"), || {
panic!("key1 should still be present")
});
assert_eq!(*v1, 1);
let v2 = cache.entry(TestLookupKey("key2"), || {
panic!("key2 should still be present")
});
assert_eq!(*v2, 2);
// key3 should have been evicted
let mut key3_recreated = false;
cache.entry(TestLookupKey("key3"), || {
key3_recreated = true;
30
});
assert!(key3_recreated, "key3 should have been evicted as the LRU");
}
}
+531
View File
@@ -0,0 +1,531 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Resolution of dynamic properties within a context.
pub(crate) mod range;
pub(crate) mod tree;
pub(crate) use range::RangedStyleBuilder;
use alloc::{vec, vec::Vec};
use super::style::{
Brush, FontFamily, FontFamilyName, FontFeature, FontFeatures, FontStyle, FontVariation,
FontVariations, FontWeight, FontWidth, StyleProperty,
};
use crate::font::FontContext;
use crate::style::TextStyle;
use crate::util::nearly_eq;
use crate::{LineHeight, OverflowWrap, layout};
use crate::{TextWrapMode, WordBreak};
use core::borrow::Borrow;
use core::ops::Range;
use fontique::FamilyId;
use fontique::Language;
/// Style with an associated range.
#[derive(Debug, Clone)]
pub(crate) struct RangedStyle<B: Brush> {
pub(crate) style: ResolvedStyle<B>,
pub(crate) range: Range<usize>,
}
/// Run that references a style in a shared style table.
#[derive(Debug, Clone)]
pub(crate) struct StyleRun {
pub(crate) style_index: u16,
pub(crate) range: Range<usize>,
}
#[derive(Clone)]
struct RangedProperty<B: Brush> {
property: ResolvedProperty<B>,
range: Range<usize>,
}
/// Handle for a managed property.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub(crate) struct Resolved<T> {
index: usize,
_phantom: core::marker::PhantomData<T>,
}
impl<T> Default for Resolved<T> {
fn default() -> Self {
Self {
index: !0,
_phantom: core::marker::PhantomData,
}
}
}
impl<T> Resolved<T> {
pub(crate) fn id(&self) -> usize {
self.index
}
}
#[derive(Clone)]
struct Cache<T> {
/// Items in the cache. May contain sequences.
items: Vec<T>,
/// Each entry represents a range of items in `data`.
entries: Vec<(usize, usize)>,
}
impl<T> Default for Cache<T> {
fn default() -> Self {
Self {
items: vec![],
entries: vec![],
}
}
}
impl<T: Clone + PartialEq> Cache<T> {
pub(crate) fn clear(&mut self) {
self.items.clear();
self.entries.clear();
}
pub(crate) fn insert(&mut self, items: &[T]) -> Resolved<T> {
for (i, entry) in self.entries.iter().enumerate() {
let range = entry.0..entry.1;
if range.len() != items.len() {
continue;
}
if let Some(existing) = self.items.get(range) {
if existing == items {
return Resolved {
index: i,
_phantom: core::marker::PhantomData,
};
}
}
}
let index = self.entries.len();
let start = self.items.len();
self.items.extend(items.iter().cloned());
let end = self.items.len();
self.entries.push((start, end));
Resolved {
index,
_phantom: core::marker::PhantomData,
}
}
pub(crate) fn get(&self, handle: Resolved<T>) -> Option<&[T]> {
let (start, end) = *self.entries.get(handle.index)?;
self.items.get(start..end)
}
}
/// Context for managing dynamic properties during layout.
#[derive(Clone, Default)]
pub(crate) struct ResolveContext {
families: Cache<FamilyId>,
variations: Cache<FontVariation>,
features: Cache<FontFeature>,
tmp_families: Vec<FamilyId>,
tmp_variations: Vec<FontVariation>,
tmp_features: Vec<FontFeature>,
}
impl ResolveContext {
pub(crate) fn resolve_property<B: Brush>(
&mut self,
fcx: &mut FontContext,
property: &StyleProperty<'_, B>,
scale: f32,
) -> ResolvedProperty<B> {
use ResolvedProperty::*;
match property {
StyleProperty::FontFamily(value) => FontFamily(self.resolve_font_family(fcx, value)),
StyleProperty::FontSize(value) => FontSize(*value * scale),
StyleProperty::FontWidth(value) => FontWidth(*value),
StyleProperty::FontStyle(value) => FontStyle(*value),
StyleProperty::FontWeight(value) => FontWeight(*value),
StyleProperty::FontVariations(value) => FontVariations(self.resolve_variations(value)),
StyleProperty::FontFeatures(value) => FontFeatures(self.resolve_features(value)),
StyleProperty::Locale(value) => Locale(*value),
StyleProperty::Brush(value) => Brush(value.clone()),
StyleProperty::Underline(value) => Underline(*value),
StyleProperty::UnderlineOffset(value) => UnderlineOffset(value.map(|x| x * scale)),
StyleProperty::UnderlineSize(value) => UnderlineSize(value.map(|x| x * scale)),
StyleProperty::UnderlineBrush(value) => UnderlineBrush(value.clone()),
StyleProperty::Strikethrough(value) => Strikethrough(*value),
StyleProperty::StrikethroughOffset(value) => {
StrikethroughOffset(value.map(|x| x * scale))
}
StyleProperty::StrikethroughSize(value) => StrikethroughSize(value.map(|x| x * scale)),
StyleProperty::StrikethroughBrush(value) => StrikethroughBrush(value.clone()),
StyleProperty::LineHeight(value) => LineHeight(value.scale(scale)),
StyleProperty::WordSpacing(value) => WordSpacing(*value * scale),
StyleProperty::LetterSpacing(value) => LetterSpacing(*value * scale),
StyleProperty::WordBreak(value) => WordBreak(*value),
StyleProperty::OverflowWrap(value) => OverflowWrap(*value),
StyleProperty::TextWrapMode(value) => TextWrapMode(*value),
}
}
pub(crate) fn resolve_entire_style_set<B: Brush>(
&mut self,
fcx: &mut FontContext,
raw_style: &TextStyle<'_, '_, B>,
scale: f32,
) -> ResolvedStyle<B> {
ResolvedStyle {
font_family: self.resolve_font_family(fcx, &raw_style.font_family),
font_size: raw_style.font_size * scale,
font_width: raw_style.font_width,
font_style: raw_style.font_style,
font_weight: raw_style.font_weight,
font_variations: self.resolve_variations(&raw_style.font_variations),
font_features: self.resolve_features(&raw_style.font_features),
locale: raw_style.locale,
brush: raw_style.brush.clone(),
underline: ResolvedDecoration {
enabled: raw_style.has_underline,
offset: raw_style.underline_offset.map(|x| x * scale),
size: raw_style.underline_size.map(|x| x * scale),
brush: raw_style.underline_brush.clone(),
},
strikethrough: ResolvedDecoration {
enabled: raw_style.has_strikethrough,
offset: raw_style.strikethrough_offset.map(|x| x * scale),
size: raw_style.strikethrough_size.map(|x| x * scale),
brush: raw_style.strikethrough_brush.clone(),
},
line_height: raw_style.line_height.scale(scale),
word_spacing: raw_style.word_spacing * scale,
letter_spacing: raw_style.letter_spacing * scale,
word_break: raw_style.word_break,
overflow_wrap: raw_style.overflow_wrap,
text_wrap_mode: raw_style.text_wrap_mode,
}
}
/// Resolves a `font-family` value.
pub(crate) fn resolve_font_family(
&mut self,
fcx: &mut FontContext,
value: &FontFamily<'_>,
) -> Resolved<FamilyId> {
self.tmp_families.clear();
match value {
FontFamily::Source(source) => {
for family in FontFamilyName::parse_css_list(source).map_while(Result::ok) {
match family {
FontFamilyName::Named(name) => {
if let Some(family) = fcx.collection.family_by_name(&name) {
self.tmp_families.push(family.id());
}
}
FontFamilyName::Generic(family) => {
self.tmp_families
.extend(fcx.collection.generic_families(family));
}
}
}
}
FontFamily::Single(family) => match family {
FontFamilyName::Named(name) => {
if let Some(family) = fcx.collection.family_by_name(name) {
self.tmp_families.push(family.id());
}
}
FontFamilyName::Generic(family) => {
self.tmp_families
.extend(fcx.collection.generic_families(*family));
}
},
FontFamily::List(families) => {
let families: &[FontFamilyName<'_>] = families.borrow();
for family in families {
match family {
FontFamilyName::Named(name) => {
if let Some(family) = fcx.collection.family_by_name(name) {
self.tmp_families.push(family.id());
}
}
FontFamilyName::Generic(family) => {
self.tmp_families
.extend(fcx.collection.generic_families(*family));
}
}
}
}
}
let resolved = self.families.insert(&self.tmp_families);
self.tmp_families.clear();
resolved
}
/// Resolves font variation settings.
pub(crate) fn resolve_variations(
&mut self,
variations: &FontVariations<'_>,
) -> Resolved<FontVariation> {
match variations {
FontVariations::Source(source) => {
self.tmp_variations.clear();
self.tmp_variations
.extend(FontVariation::parse_css_list(source).map_while(Result::ok));
}
FontVariations::List(settings) => {
self.tmp_variations.clear();
self.tmp_variations.extend_from_slice(settings);
}
}
if self.tmp_variations.is_empty() {
return Resolved::default();
}
self.tmp_variations.sort_by_key(|a| a.tag);
let resolved = self.variations.insert(&self.tmp_variations);
self.tmp_variations.clear();
resolved
}
/// Resolves font feature settings.
pub(crate) fn resolve_features(
&mut self,
features: &FontFeatures<'_>,
) -> Resolved<FontFeature> {
match features {
FontFeatures::Source(source) => {
self.tmp_features.clear();
self.tmp_features
.extend(FontFeature::parse_css_list(source).map_while(Result::ok));
}
FontFeatures::List(settings) => {
self.tmp_features.clear();
self.tmp_features.extend_from_slice(settings);
}
}
if self.tmp_features.is_empty() {
return Resolved::default();
}
self.tmp_features.sort_by_key(|a| a.tag);
let resolved = self.features.insert(&self.tmp_features);
self.tmp_features.clear();
resolved
}
/// Returns the list of font families for the specified handle.
pub(crate) fn stack(&self, stack: Resolved<FamilyId>) -> Option<&[FamilyId]> {
self.families.get(stack)
}
/// Returns the list of font variations for the specified handle.
pub(crate) fn variations(
&self,
variations: Resolved<FontVariation>,
) -> Option<&[FontVariation]> {
self.variations.get(variations)
}
/// Returns the list of font features for the specified handle.
pub(crate) fn features(&self, features: Resolved<FontFeature>) -> Option<&[FontFeature]> {
self.features.get(features)
}
/// Clears the resources in the context.
pub(crate) fn clear(&mut self) {
self.families.clear();
self.variations.clear();
self.features.clear();
}
}
/// Style property with resolved resources.
#[derive(Clone, PartialEq)]
pub(crate) enum ResolvedProperty<B: Brush> {
/// `font-family`.
FontFamily(Resolved<FamilyId>),
/// Font size.
FontSize(f32),
/// Font width.
FontWidth(FontWidth),
/// Font style.
FontStyle(FontStyle),
/// Font weight.
FontWeight(FontWeight),
/// Font variation settings.
FontVariations(Resolved<FontVariation>),
/// Font feature settings.
FontFeatures(Resolved<FontFeature>),
/// Locale.
Locale(Option<Language>),
/// Brush for rendering text.
Brush(B),
/// Underline decoration.
Underline(bool),
/// Offset of the underline decoration.
UnderlineOffset(Option<f32>),
/// Size of the underline decoration.
UnderlineSize(Option<f32>),
/// Brush for rendering the underline decoration.
UnderlineBrush(Option<B>),
/// Strikethrough decoration.
Strikethrough(bool),
/// Offset of the strikethrough decoration.
StrikethroughOffset(Option<f32>),
/// Size of the strikethrough decoration.
StrikethroughSize(Option<f32>),
/// Brush for rendering the strikethrough decoration.
StrikethroughBrush(Option<B>),
/// Line height.
LineHeight(LineHeight),
/// Extra spacing between words.
WordSpacing(f32),
/// Extra spacing between letters.
LetterSpacing(f32),
/// Control over where words can wrap.
WordBreak(WordBreak),
/// Control over "emergency" line-breaking.
OverflowWrap(OverflowWrap),
/// Control over non-"emergency" line-breaking.
TextWrapMode(TextWrapMode),
}
/// Flattened group of style properties.
#[derive(Clone, PartialEq, Debug, Default)]
pub(crate) struct ResolvedStyle<B: Brush> {
/// `font-family`.
pub(crate) font_family: Resolved<FamilyId>,
/// Font size.
pub(crate) font_size: f32,
/// Font width.
pub(crate) font_width: FontWidth,
/// Font style.
pub(crate) font_style: FontStyle,
/// Font weight.
pub(crate) font_weight: FontWeight,
/// Font variation settings.
pub(crate) font_variations: Resolved<FontVariation>,
/// Font feature settings.
pub(crate) font_features: Resolved<FontFeature>,
/// Locale.
pub(crate) locale: Option<Language>,
/// Brush for rendering text.
pub(crate) brush: B,
/// Underline decoration.
pub(crate) underline: ResolvedDecoration<B>,
/// Strikethrough decoration.
pub(crate) strikethrough: ResolvedDecoration<B>,
/// Line height.
pub(crate) line_height: LineHeight,
/// Extra spacing between words.
pub(crate) word_spacing: f32,
/// Extra spacing between letters.
pub(crate) letter_spacing: f32,
/// Control over where words can wrap.
pub(crate) word_break: WordBreak,
/// Control over "emergency" line-breaking.
pub(crate) overflow_wrap: OverflowWrap,
/// Control over non-"emergency" line-breaking.
pub(crate) text_wrap_mode: TextWrapMode,
}
impl<B: Brush> ResolvedStyle<B> {
/// Applies the specified property to this style.
pub(crate) fn apply(&mut self, property: ResolvedProperty<B>) {
use ResolvedProperty::*;
match property {
FontFamily(value) => self.font_family = value,
FontSize(value) => self.font_size = value,
FontWidth(value) => self.font_width = value,
FontStyle(value) => self.font_style = value,
FontWeight(value) => self.font_weight = value,
FontVariations(value) => self.font_variations = value,
FontFeatures(value) => self.font_features = value,
Locale(value) => self.locale = value,
Brush(value) => self.brush = value,
Underline(value) => self.underline.enabled = value,
UnderlineOffset(value) => self.underline.offset = value,
UnderlineSize(value) => self.underline.size = value,
UnderlineBrush(value) => self.underline.brush = value,
Strikethrough(value) => self.strikethrough.enabled = value,
StrikethroughOffset(value) => self.strikethrough.offset = value,
StrikethroughSize(value) => self.strikethrough.size = value,
StrikethroughBrush(value) => self.strikethrough.brush = value,
LineHeight(value) => self.line_height = value,
WordSpacing(value) => self.word_spacing = value,
LetterSpacing(value) => self.letter_spacing = value,
WordBreak(value) => self.word_break = value,
OverflowWrap(value) => self.overflow_wrap = value,
TextWrapMode(value) => self.text_wrap_mode = value,
}
}
pub(crate) fn check(&self, property: &ResolvedProperty<B>) -> bool {
use ResolvedProperty::*;
match property {
FontFamily(value) => self.font_family == *value,
FontSize(value) => nearly_eq(self.font_size, *value),
FontWidth(value) => self.font_width == *value,
FontStyle(value) => self.font_style == *value,
FontWeight(value) => self.font_weight == *value,
FontVariations(value) => self.font_variations == *value,
FontFeatures(value) => self.font_features == *value,
Locale(value) => self.locale == *value,
Brush(value) => self.brush == *value,
Underline(value) => self.underline.enabled == *value,
UnderlineOffset(value) => self.underline.offset == *value,
UnderlineSize(value) => self.underline.size == *value,
UnderlineBrush(value) => self.underline.brush == *value,
Strikethrough(value) => self.strikethrough.enabled == *value,
StrikethroughOffset(value) => self.strikethrough.offset == *value,
StrikethroughSize(value) => self.strikethrough.size == *value,
StrikethroughBrush(value) => self.strikethrough.brush == *value,
LineHeight(value) => self.line_height.nearly_eq(*value),
WordSpacing(value) => nearly_eq(self.word_spacing, *value),
LetterSpacing(value) => nearly_eq(self.letter_spacing, *value),
WordBreak(value) => self.word_break == *value,
OverflowWrap(value) => self.overflow_wrap == *value,
TextWrapMode(value) => self.text_wrap_mode == *value,
}
}
pub(crate) fn as_layout_style(&self) -> layout::Style<B> {
layout::Style {
brush: self.brush.clone(),
underline: self.underline.as_layout_decoration(&self.brush),
strikethrough: self.strikethrough.as_layout_decoration(&self.brush),
line_height: self.line_height,
overflow_wrap: self.overflow_wrap,
text_wrap_mode: self.text_wrap_mode,
#[cfg(feature = "accesskit")]
locale: self.locale,
}
}
}
/// Underline or strikethrough decoration.
#[derive(Clone, PartialEq, Default, Debug)]
pub(crate) struct ResolvedDecoration<B: Brush> {
/// True if the decoration is enabled.
pub(crate) enabled: bool,
/// Offset of the decoration from the baseline.
pub(crate) offset: Option<f32>,
/// Thickness of the decoration stroke.
pub(crate) size: Option<f32>,
/// Brush for the decoration.
pub(crate) brush: Option<B>,
}
impl<B: Brush> ResolvedDecoration<B> {
/// Convert into a layout Decoration (filtering out disabled decorations)
pub(crate) fn as_layout_decoration(&self, default_brush: &B) -> Option<layout::Decoration<B>> {
if self.enabled {
Some(layout::Decoration {
brush: self.brush.clone().unwrap_or_else(|| default_brush.clone()),
offset: self.offset,
size: self.size,
})
} else {
None
}
}
}
+224
View File
@@ -0,0 +1,224 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Range based style application.
use alloc::vec;
use super::{Brush, RangedProperty, RangedStyle, ResolvedProperty, ResolvedStyle, StyleRun, Vec};
use core::ops::{Bound, Range, RangeBounds};
/// Builder for constructing an ordered sequence of non-overlapping ranged
/// styles from a collection of ranged style properties.
#[derive(Clone)]
pub(crate) struct RangedStyleBuilder<B: Brush> {
properties: Vec<RangedProperty<B>>,
scratch_styles: Vec<RangedStyle<B>>,
root_style: ResolvedStyle<B>,
len: usize,
}
impl<B: Brush> Default for RangedStyleBuilder<B> {
fn default() -> Self {
Self {
properties: vec![],
scratch_styles: vec![],
root_style: ResolvedStyle::default(),
// We use `usize::MAX` as a sentinel that `begin` hasn't been called.
// This is required (rather than requiring the root style in the constructor)
// as we want to support re-using this value.
len: usize::MAX,
}
}
}
impl<B: Brush> RangedStyleBuilder<B> {
/// Prepares the builder for accepting ranged properties for text of the specified length.
///
/// The provided `root_style` is the default style applied to all text unless overridden.
pub(crate) fn begin(&mut self, root_style: ResolvedStyle<B>, len: usize) {
self.properties.clear();
self.scratch_styles.clear();
self.root_style = root_style;
self.len = len;
}
/// Change a property of the root style, which covers the full range of text.
///
/// # Panics
///
/// If [`begin`](Self::begin) has not been called before using this method.
pub(crate) fn push_default(&mut self, property: ResolvedProperty<B>) {
assert!(
self.len != usize::MAX,
"Internal error: Must call `begin` before setting properties on a `RangedStyleBuilder`."
);
self.root_style.apply(property);
}
/// Override a property for the specified range of text.
///
/// # Panics
///
/// If [`begin`](Self::begin) has not been called before using this method.
pub(crate) fn push(&mut self, property: ResolvedProperty<B>, range: impl RangeBounds<usize>) {
assert!(
self.len != usize::MAX,
"Internal error: Must call `begin` before setting properties on a `RangedStyleBuilder`."
);
let range = resolve_range(range, self.len);
self.properties.push(RangedProperty { property, range });
}
/// Computes style table + style runs for the ranged properties.
pub(crate) fn finish(
&mut self,
style_table: &mut Vec<ResolvedStyle<B>>,
style_runs: &mut Vec<StyleRun>,
) {
style_table.clear();
style_runs.clear();
if self.len == usize::MAX {
self.properties.clear();
self.scratch_styles.clear();
self.root_style = ResolvedStyle::default();
return;
}
let styles = &mut self.scratch_styles;
styles.push(RangedStyle {
style: self.root_style.clone(),
range: 0..self.len,
});
for prop in &self.properties {
if prop.range.start > prop.range.end {
continue;
}
let split_range = split_range(prop, styles);
let mut inserted = 0;
if let Some(first) = split_range.first {
let original_span = &mut styles[first];
if !original_span.style.check(&prop.property) {
let mut new_span = original_span.clone();
let original_end = original_span.range.end;
original_span.range.end = prop.range.start;
new_span.range.start = prop.range.start;
new_span.style.apply(prop.property.clone());
if split_range.replace_len == 0 && split_range.last == Some(first) {
let mut new_end_span = original_span.clone();
new_end_span.range.start = prop.range.end;
new_end_span.range.end = original_end;
new_span.range.end = prop.range.end;
styles.splice(
first + 1..first + 1,
[new_span, new_end_span].iter().cloned(),
);
continue;
} else {
styles.insert(first + 1, new_span);
}
inserted += 1;
}
}
let replace_start = split_range.replace_start + inserted;
let replace_end = replace_start + split_range.replace_len;
for style in &mut styles[replace_start..replace_end] {
style.style.apply(prop.property.clone());
}
if let Some(mut last) = split_range.last {
last += inserted;
let original_span = &mut styles[last];
if !original_span.style.check(&prop.property) {
let mut new_span = original_span.clone();
original_span.range.start = prop.range.end;
new_span.range.end = prop.range.end;
new_span.style.apply(prop.property.clone());
styles.insert(last, new_span);
}
}
}
let mut prev_index = 0;
let mut merged_count = 0;
for i in 1..styles.len() {
if styles[prev_index].style == styles[i].style {
let end = styles[i].range.end;
styles[prev_index].range.end = end;
merged_count += 1;
} else {
prev_index += 1;
if prev_index != i {
let moved_span = styles[i].clone();
styles[prev_index] = moved_span;
}
}
}
styles.truncate(styles.len() - merged_count);
style_table.reserve(styles.len());
style_runs.reserve(styles.len());
for (style_index, style) in styles.drain(..).enumerate() {
style_table.push(style.style);
style_runs.push(StyleRun {
style_index: style_index as u16,
range: style.range,
});
}
self.properties.clear();
self.root_style = ResolvedStyle::default();
self.len = usize::MAX;
}
}
#[derive(Default)]
struct SplitRange {
first: Option<usize>,
replace_start: usize,
replace_len: usize,
last: Option<usize>,
}
fn split_range<B: Brush>(prop: &RangedProperty<B>, spans: &[RangedStyle<B>]) -> SplitRange {
let mut range = SplitRange::default();
let start_span_index =
match spans.binary_search_by(|span| span.range.start.cmp(&prop.range.start)) {
Ok(index) => index,
Err(index) => index.saturating_sub(1),
};
let mut end_span_index = spans.len() - 1;
for (i, span) in spans[start_span_index..].iter().enumerate() {
if span.range.end >= prop.range.end {
end_span_index = i + start_span_index;
break;
}
}
let start_span = &spans[start_span_index];
let end_span = &spans[end_span_index];
if start_span.range.start < prop.range.start {
range.first = Some(start_span_index);
range.replace_start = start_span_index + 1;
} else {
range.replace_start = start_span_index;
}
if end_span.range.end > prop.range.end {
range.last = Some(end_span_index);
range.replace_len = end_span_index.saturating_sub(range.replace_start);
} else {
range.replace_len = (end_span_index + 1).saturating_sub(range.replace_start);
}
range
}
/// Resolves a `RangeBounds` into a range in the range 0..len.
fn resolve_range(range: impl RangeBounds<usize>, len: usize) -> Range<usize> {
let start = match range.start_bound() {
Bound::Unbounded => 0,
Bound::Included(n) => *n,
Bound::Excluded(n) => *n + 1,
};
let end = match range.end_bound() {
Bound::Unbounded => len,
Bound::Included(n) => *n + 1,
Bound::Excluded(n) => *n,
};
start.min(len)..end.min(len)
}
+310
View File
@@ -0,0 +1,310 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Hierarchical tree based style application.
use alloc::{string::String, vec::Vec};
use crate::style::WhiteSpaceCollapse;
use super::{Brush, ResolvedProperty, ResolvedStyle, StyleRun};
#[derive(Debug, Clone)]
struct StyleTreeNode<B: Brush> {
parent: Option<usize>,
style: ResolvedStyle<B>,
style_id: Option<u16>,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum ItemKind {
None,
InlineBox,
TextRun,
}
/// Builder for constructing a tree of styles
#[derive(Clone)]
pub(crate) struct TreeStyleBuilder<B: Brush> {
tree: Vec<StyleTreeNode<B>>,
style_table: Vec<ResolvedStyle<B>>,
style_runs: Vec<StyleRun>,
white_space_collapse: WhiteSpaceCollapse,
text: String,
uncommitted_text: String,
current_span: usize,
is_span_first: bool,
last_item_kind: ItemKind,
}
impl<B: Brush> TreeStyleBuilder<B> {
fn current_style(&self) -> ResolvedStyle<B> {
self.tree[self.current_span].style.clone()
}
}
impl<B: Brush> Default for TreeStyleBuilder<B> {
fn default() -> Self {
Self {
tree: Vec::new(),
style_table: Vec::new(),
style_runs: Vec::new(),
white_space_collapse: WhiteSpaceCollapse::Preserve,
text: String::new(),
uncommitted_text: String::new(),
current_span: usize::MAX,
is_span_first: false,
last_item_kind: ItemKind::None,
}
}
}
impl<B: Brush> TreeStyleBuilder<B> {
/// Prepares the builder for accepting a tree of styles and text.
///
/// The provided `root_style` is the default style applied to all text unless overridden.
pub(crate) fn begin(&mut self, root_style: ResolvedStyle<B>) {
self.tree.clear();
self.style_table.clear();
self.style_runs.clear();
self.white_space_collapse = WhiteSpaceCollapse::Preserve;
self.text.clear();
self.uncommitted_text.clear();
self.tree.push(StyleTreeNode {
parent: None,
style: root_style,
style_id: None,
});
self.current_span = 0;
self.is_span_first = true;
}
pub(crate) fn set_white_space_mode(&mut self, white_space_collapse: WhiteSpaceCollapse) {
self.white_space_collapse = white_space_collapse;
}
pub(crate) fn set_is_span_first(&mut self, is_span_first: bool) {
self.is_span_first = is_span_first;
}
pub(crate) fn set_last_item_kind(&mut self, item_kind: ItemKind) {
self.last_item_kind = item_kind;
}
pub(crate) fn push_uncommitted_text(&mut self, is_span_last: bool) {
let uncommitted_text = core::mem::take(&mut self.uncommitted_text);
let span_text = match self.white_space_collapse {
WhiteSpaceCollapse::Preserve => uncommitted_text,
WhiteSpaceCollapse::Collapse => {
let mut span_text = uncommitted_text.as_str();
if self.is_span_first
|| (self.last_item_kind == ItemKind::TextRun
&& self
.text
.chars()
.last()
.is_some_and(|c| c.is_ascii_whitespace()))
{
span_text = span_text.trim_start();
}
if is_span_last {
span_text = span_text.trim_end();
}
// Collapse spaces
let mut last_char_whitespace = false;
span_text
.chars()
.filter_map(|c: char| {
let this_char_whitespace = c.is_ascii_whitespace();
let prev_char_whitespace = last_char_whitespace;
last_char_whitespace = this_char_whitespace;
if this_char_whitespace {
if prev_char_whitespace {
None
} else {
Some(' ')
}
} else {
Some(c)
}
})
.collect()
}
};
// Nothing to do if there is no uncommitted text.
if span_text.is_empty() {
return;
}
let range = self.text.len()..(self.text.len() + span_text.len());
let style_index = self.resolve_current_style_id();
self.style_runs.push(StyleRun { style_index, range });
self.text.push_str(&span_text);
self.is_span_first = false;
self.last_item_kind = ItemKind::TextRun;
}
fn resolve_current_style_id(&mut self) -> u16 {
if let Some(style_id) = self.tree[self.current_span].style_id {
return style_id;
}
let style_id = self.style_table.len() as u16;
self.style_table.push(self.current_style());
self.tree[self.current_span].style_id = Some(style_id);
style_id
}
pub(crate) fn current_text_len(&self) -> usize {
self.text.len()
}
pub(crate) fn push_style_span(&mut self, style: ResolvedStyle<B>) {
self.push_uncommitted_text(false);
self.tree.push(StyleTreeNode {
parent: Some(self.current_span),
style,
style_id: None,
});
self.current_span = self.tree.len() - 1;
self.is_span_first = true;
}
pub(crate) fn push_style_modification_span(
&mut self,
properties: impl Iterator<Item = ResolvedProperty<B>>,
) {
let mut style = self.current_style();
for prop in properties {
style.apply(prop.clone());
}
self.push_style_span(style);
}
pub(crate) fn pop_style_span(&mut self) {
self.push_uncommitted_text(true);
self.current_span = self.tree[self.current_span]
.parent
.expect("Popped root style");
}
/// Pushes a property that covers the specified range of text.
pub(crate) fn push_text(&mut self, text: &str) {
if !text.is_empty() {
self.uncommitted_text.push_str(text);
}
}
/// Computes style table + style runs and returns the final text buffer.
pub(crate) fn finish(
&mut self,
style_table: &mut Vec<ResolvedStyle<B>>,
style_runs: &mut Vec<StyleRun>,
) -> String {
while self.tree[self.current_span].parent.is_some() {
self.pop_style_span();
}
self.push_uncommitted_text(true);
style_table.clear();
style_runs.clear();
style_table.extend_from_slice(&self.style_table);
style_runs.extend_from_slice(&self.style_runs);
core::mem::take(&mut self.text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec::Vec;
use core::ops::Range;
#[test]
fn reuses_style_id_when_returning_to_parent_span() {
let mut builder = TreeStyleBuilder::<u32>::default();
builder.begin(ResolvedStyle::default());
builder.push_text("A");
builder.push_style_modification_span([ResolvedProperty::FontSize(20.)].into_iter());
builder.push_text("B");
builder.pop_style_span();
builder.push_text("C");
let mut style_table = Vec::new();
let mut style_runs = Vec::new();
let text = builder.finish(&mut style_table, &mut style_runs);
assert_eq!(text, "ABC");
assert_eq!(style_table.len(), 2);
assert_eq!(style_runs.len(), 3);
assert_eq!(style_runs[0].style_index, 0);
assert_eq!(style_runs[1].style_index, 1);
assert_eq!(style_runs[2].style_index, 0);
assert_eq!(style_runs[0].range, Range { start: 0, end: 1 });
assert_eq!(style_runs[1].range, Range { start: 1, end: 2 });
assert_eq!(style_runs[2].range, Range { start: 2, end: 3 });
}
#[test]
fn reuses_root_style_id_across_multiple_pop_return_cycles() {
let mut builder = TreeStyleBuilder::<u32>::default();
builder.begin(ResolvedStyle::default());
builder.push_text("A");
builder.push_style_modification_span([ResolvedProperty::FontSize(20.)].into_iter());
builder.push_text("B");
builder.pop_style_span();
builder.push_text("C");
builder.push_style_modification_span([ResolvedProperty::LetterSpacing(1.)].into_iter());
builder.push_text("D");
builder.pop_style_span();
builder.push_text("E");
let mut style_table = Vec::new();
let mut style_runs = Vec::new();
let text = builder.finish(&mut style_table, &mut style_runs);
assert_eq!(text, "ABCDE");
assert_eq!(style_table.len(), 3);
assert_eq!(style_runs.len(), 5);
assert_eq!(style_runs[0].style_index, 0);
assert_eq!(style_runs[1].style_index, 1);
assert_eq!(style_runs[2].style_index, 0);
assert_eq!(style_runs[3].style_index, 2);
assert_eq!(style_runs[4].style_index, 0);
}
#[test]
fn reuses_parent_and_root_style_ids_after_nested_pop() {
let mut builder = TreeStyleBuilder::<u32>::default();
builder.begin(ResolvedStyle::default());
builder.push_text("R");
builder.push_style_modification_span([ResolvedProperty::FontSize(20.)].into_iter());
builder.push_text("A");
builder.push_style_modification_span([ResolvedProperty::LetterSpacing(1.)].into_iter());
builder.push_text("B");
builder.pop_style_span();
builder.push_text("C");
builder.pop_style_span();
builder.push_text("D");
let mut style_table = Vec::new();
let mut style_runs = Vec::new();
let text = builder.finish(&mut style_table, &mut style_runs);
assert_eq!(text, "RABCD");
assert_eq!(style_table.len(), 3);
assert_eq!(style_runs.len(), 5);
assert_eq!(style_runs[0].style_index, 0);
assert_eq!(style_runs[1].style_index, 1);
assert_eq!(style_runs[2].style_index, 2);
assert_eq!(style_runs[3].style_index, 1);
assert_eq!(style_runs[4].style_index, 0);
}
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! OpenType settings (features and variations).
pub use parlance::{FontFeature, FontVariation, Tag};
+178
View File
@@ -0,0 +1,178 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use crate::FontVariation;
use alloc::boxed::Box;
use hashbrown::Equivalent;
#[derive(PartialEq, Copy, Clone)]
pub(crate) struct ShapeDataKey {
/// The font collection's blob ID.
font_blob_id: u64,
/// The font's index in the font collection.
font_index: u32,
}
impl ShapeDataKey {
pub(crate) const fn new(font_blob_id: u64, font_index: u32) -> Self {
Self {
font_blob_id,
font_index,
}
}
}
impl Equivalent<Self> for ShapeDataKey {
#[inline(always)]
fn equivalent(&self, key: &Self) -> bool {
self == key
}
}
impl From<&Self> for ShapeDataKey {
#[inline(always)]
fn from(key: &Self) -> Self {
*key
}
}
pub(crate) struct ShapeInstanceId {
/// The font collection's blob ID.
font_blob_id: u64,
/// The font's index in the font collection.
font_index: u32,
synthesis: fontique::Synthesis,
variations: Option<Box<[FontVariation]>>,
}
pub(crate) struct ShapeInstanceKey<'a> {
/// The font collection's blob ID.
font_blob_id: u64,
/// The font's index in the font collection.
font_index: u32,
synthesis: &'a fontique::Synthesis,
variations: Option<&'a [FontVariation]>,
}
impl<'a> ShapeInstanceKey<'a> {
pub(crate) const fn new(
font_blob_id: u64,
font_index: u32,
synthesis: &'a fontique::Synthesis,
variations: Option<&'a [FontVariation]>,
) -> Self {
Self {
font_blob_id,
font_index,
synthesis,
variations,
}
}
}
impl<'a> Equivalent<ShapeInstanceId> for ShapeInstanceKey<'a> {
#[inline(always)]
fn equivalent(&self, key: &ShapeInstanceId) -> bool {
self.font_blob_id == key.font_blob_id
&& self.font_index == key.font_index
&& *self.synthesis == key.synthesis
&& self.variations == key.variations.as_deref()
}
}
impl<'a> From<ShapeInstanceKey<'a>> for ShapeInstanceId {
#[inline(always)]
fn from(key: ShapeInstanceKey<'a>) -> Self {
Self {
font_blob_id: key.font_blob_id,
font_index: key.font_index,
synthesis: *key.synthesis,
variations: key.variations.map(|v| v.to_vec().into()),
}
}
}
pub(crate) struct ShapePlanId {
/// The font collection's blob ID.
font_blob_id: u64,
/// The font's index in the font collection.
font_index: u32,
synthesis: fontique::Synthesis,
direction: harfrust::Direction,
script: harfrust::Script,
language: Option<harfrust::Language>,
features: Box<[harfrust::Feature]>,
variations: Option<Box<[FontVariation]>>,
}
pub(crate) struct ShapePlanKey<'a> {
/// The font collection's blob ID.
font_blob_id: u64,
/// The font's index in the font collection.
font_index: u32,
synthesis: &'a fontique::Synthesis,
direction: harfrust::Direction,
script: harfrust::Script,
language: Option<harfrust::Language>,
features: &'a [harfrust::Feature],
variations: Option<&'a [FontVariation]>,
}
impl<'a> ShapePlanKey<'a> {
pub(crate) const fn new(
font_blob_id: u64,
font_index: u32,
synthesis: &'a fontique::Synthesis,
direction: harfrust::Direction,
script: harfrust::Script,
language: Option<harfrust::Language>,
features: &'a [harfrust::Feature],
variations: Option<&'a [FontVariation]>,
) -> Self {
Self {
font_blob_id,
font_index,
synthesis,
direction,
script,
language,
features,
variations,
}
}
}
impl<'a> Equivalent<ShapePlanId> for ShapePlanKey<'a> {
#[inline(always)]
fn equivalent(&self, key: &ShapePlanId) -> bool {
self.font_blob_id == key.font_blob_id
&& self.font_index == key.font_index
&& *self.synthesis == key.synthesis
&& self.direction == key.direction
&& self.script == key.script
&& self.language == key.language
&& self.features.len() == key.features.len()
&& self.variations == key.variations.as_deref()
&& self
.features
.iter()
.zip(key.features.iter())
.all(|(a, b)| a == b)
}
}
impl<'a> From<ShapePlanKey<'a>> for ShapePlanId {
#[inline(always)]
fn from(key: ShapePlanKey<'a>) -> Self {
Self {
font_blob_id: key.font_blob_id,
font_index: key.font_index,
synthesis: *key.synthesis,
direction: key.direction,
script: key.script,
language: key.language,
features: key.features.to_vec().into(),
variations: key.variations.map(|v| v.to_vec().into()),
}
}
}
+672
View File
@@ -0,0 +1,672 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Text shaping implementation using `harfrust`for shaping
//! and `icu` for text analysis.
use alloc::vec::Vec;
use core::mem;
use core::ops::RangeInclusive;
use harfrust::ShapeOptions;
use super::layout::Layout;
use super::resolve::{ResolveContext, Resolved, ResolvedStyle};
use super::style::{Brush, FontFeature, FontVariation};
use crate::analysis::cluster::{Char, CharCluster, Status};
use crate::analysis::{AnalysisDataSources, CharInfo};
use crate::convert::script_to_harfrust;
use crate::inline_box::InlineBox;
use crate::lru_cache::LruCache;
use crate::util::nearly_eq;
use crate::{FontData, convert};
use fontique::Language;
use icu_properties::props::Script;
use fontique::{self, Query, QueryFamily, QueryFont};
mod cache;
pub(crate) struct ShapeContext {
shape_data_cache: LruCache<cache::ShapeDataKey, harfrust::ShaperData>,
shape_instance_cache: LruCache<cache::ShapeInstanceId, harfrust::ShaperInstance>,
shape_plan_cache: LruCache<cache::ShapePlanId, harfrust::ShapePlan>,
unicode_buffer: Option<harfrust::UnicodeBuffer>,
features: Vec<harfrust::Feature>,
char_cluster: CharCluster,
}
impl Default for ShapeContext {
fn default() -> Self {
const MAX_ENTRIES: usize = 16;
Self {
shape_data_cache: LruCache::new(MAX_ENTRIES),
shape_instance_cache: LruCache::new(MAX_ENTRIES),
shape_plan_cache: LruCache::new(MAX_ENTRIES),
unicode_buffer: Some(harfrust::UnicodeBuffer::new()),
features: Vec::new(),
char_cluster: CharCluster::default(),
}
}
}
struct Item {
style_index: u16,
size: f32,
script: Script,
level: u8,
locale: Option<Language>,
variations: Resolved<FontVariation>,
features: Resolved<FontFeature>,
word_spacing: f32,
letter_spacing: f32,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn shape_text<'a, B: Brush>(
rcx: &'a ResolveContext,
mut fq: Query<'a>,
styles: &'a [ResolvedStyle<B>],
inline_boxes: &[InlineBox],
infos: &[(CharInfo, u16)],
levels: &[u8],
scx: &mut ShapeContext,
mut text: &str,
layout: &mut Layout<B>,
analysis_data_sources: &AnalysisDataSources,
) {
// If we have both empty text and no inline boxes, shape with a fake space
// to generate metrics that can be used to size a cursor.
if text.is_empty() && inline_boxes.is_empty() {
text = " ";
}
// Do nothing if there is no text or styles (there should always be a default style)
if text.is_empty() || styles.is_empty() {
// Process any remaining inline boxes whose index is greater than the length of the text
for box_idx in 0..inline_boxes.len() {
// Push the box to the list of items
layout.data.push_inline_box(box_idx);
}
return;
}
// Setup mutable state for iteration
let initial_style_index = infos.first().map_or(0, |(_, style_index)| *style_index);
let mut style = &styles[initial_style_index as usize];
let mut item = Item {
style_index: initial_style_index,
size: style.font_size,
level: levels.first().copied().unwrap_or(0),
script: infos
.iter()
.map(|x| x.0.script)
.find(|&script| real_script(script))
.unwrap_or(Script::Latin),
locale: style.locale,
variations: style.font_variations,
features: style.font_features,
word_spacing: style.word_spacing,
letter_spacing: style.letter_spacing,
};
let mut char_range = 0..0;
let mut text_range = 0..0;
let mut inline_box_iter = inline_boxes.iter().enumerate();
let mut current_box = inline_box_iter.next();
// Iterate over characters in the text
for ((char_index, (byte_index, ch)), (info, style_index)) in
text.char_indices().enumerate().zip(infos)
{
let mut break_run = false;
let mut script = info.script;
if !real_script(script) {
script = item.script;
}
let level = levels.get(char_index).copied().unwrap_or(0);
if item.style_index != *style_index {
item.style_index = *style_index;
style = &styles[*style_index as usize];
if !nearly_eq(style.font_size, item.size)
|| style.locale != item.locale
|| style.font_variations != item.variations
|| style.font_features != item.features
|| !nearly_eq(style.letter_spacing, item.letter_spacing)
|| !nearly_eq(style.word_spacing, item.word_spacing)
{
break_run = true;
}
}
if level != item.level || script != item.script {
break_run = true;
}
// Check if there is an inline box at this index
// Note:
// - We loop because there may be multiple boxes at this index
// - We do this *before* processing the text run because we need to know whether we should
// break the run due to the presence of an inline box.
let mut deferred_boxes: Option<RangeInclusive<usize>> = None;
while let Some((box_idx, inline_box)) = current_box {
if inline_box.index == byte_index {
break_run = true;
if let Some(boxes) = &mut deferred_boxes {
deferred_boxes = Some((*boxes.start())..=box_idx);
} else {
deferred_boxes = Some(box_idx..=box_idx);
};
// Update the current box to the next box
current_box = inline_box_iter.next();
} else {
break;
}
}
if break_run && !text_range.is_empty() {
shape_item(
&mut fq,
rcx,
styles,
&item,
scx,
text,
&text_range,
&char_range,
infos,
layout,
analysis_data_sources,
);
item.size = style.font_size;
item.level = level;
item.script = script;
item.locale = style.locale;
item.variations = style.font_variations;
item.features = style.font_features;
item.word_spacing = style.word_spacing;
item.letter_spacing = style.letter_spacing;
text_range.start = text_range.end;
char_range.start = char_range.end;
}
if let Some(deferred_boxes) = deferred_boxes {
for box_idx in deferred_boxes {
layout.data.push_inline_box(box_idx);
}
}
text_range.end += ch.len_utf8();
char_range.end += 1;
}
if !text_range.is_empty() {
shape_item(
&mut fq,
rcx,
styles,
&item,
scx,
text,
&text_range,
&char_range,
infos,
layout,
analysis_data_sources,
);
}
// Process any remaining inline boxes whose index is greater than the length of the text
if let Some((box_idx, _inline_box)) = current_box {
layout.data.push_inline_box(box_idx);
}
for (box_idx, _inline_box) in inline_box_iter {
layout.data.push_inline_box(box_idx);
}
}
// Rebuilds the provided `char_cluster` in-place using the existing allocation
// for the given grapheme `segment_text`, consuming items from `item_infos_iter`.
fn fill_cluster_in_place(
segment_text: &str,
item_infos_iter: &mut core::slice::Iter<'_, (CharInfo, u16)>,
code_unit_offset_in_string: &mut usize,
char_cluster: &mut CharCluster,
) {
// Reset cluster but keep allocation
char_cluster.clear();
let mut force_normalize = false;
let mut is_emoji_or_pictograph = false;
let mut map_len: u8 = 0;
let start = *code_unit_offset_in_string as u32;
for ((_, ch), (info, style_index)) in segment_text.char_indices().zip(item_infos_iter.by_ref())
{
force_normalize |= info.force_normalize();
// TODO - make emoji detection more complete, as per (except using composite Trie tables as
// much as possible:
// https://github.com/conor-93/parley/blob/4637d826732a1a82bbb3c904c7f47a16a21cceec/parley/src/shape/mod.rs#L221-L269
is_emoji_or_pictograph |= info.is_emoji_or_pictograph();
*code_unit_offset_in_string += ch.len_utf8();
// TODO: Explore ignoring other modifiers in determining `contributes_to_shaping`:
// regional indicators, subdivision flag tag sequences, skin tone modifiers
// See also: https://github.com/google/emoji-segmenter
// If the color emoji has a non-printing variation selector, ignore the variation selector.
// Its presentation depends on the platform and font.
//
// e.g.
// - `U+270C + U+FE0F`: `✌`, force basic presentation
// - `U+270C + U+FE0F`: `✌️`, force emoji presentation
//
// <https://www.unicode.org/reports/tr37/>
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;
if contributes_to_shaping {
map_len += 1;
}
char_cluster.chars.push(Char {
ch,
contributes_to_shaping,
glyph_id: 0,
style_index: *style_index,
is_control_character: info.is_control(),
});
}
// Finalize cluster metadata
let end = *code_unit_offset_in_string as u32;
char_cluster.is_emoji = is_emoji_or_pictograph;
char_cluster.map_len = map_len;
char_cluster.start = start;
char_cluster.end = end;
char_cluster.force_normalize = force_normalize;
}
fn shape_item<'a, B: Brush>(
fq: &mut Query<'a>,
rcx: &'a ResolveContext,
styles: &'a [ResolvedStyle<B>],
item: &Item,
scx: &mut ShapeContext,
text: &str,
text_range: &core::ops::Range<usize>,
char_range: &core::ops::Range<usize>,
infos: &[(CharInfo, u16)],
layout: &mut Layout<B>,
analysis_data_sources: &AnalysisDataSources,
) {
let item_text = &text[text_range.clone()];
let item_infos = &infos[char_range.start..char_range.end]; // Only process current item
let first_style_index = item_infos[0].1;
let fb_script = convert::script_to_fontique(item.script, analysis_data_sources);
let mut font_selector =
FontSelector::new(fq, rcx, styles, first_style_index, fb_script, item.locale);
let grapheme_cluster_boundaries = analysis_data_sources
.grapheme_segmenter()
.segment_str(item_text);
let mut item_infos_iter = item_infos.iter();
let mut code_unit_offset_in_string = text_range.start;
let char_cluster = &mut scx.char_cluster;
// Build an iterator of boundaries and consume the first segment to seed the loop
let mut boundaries_iter = grapheme_cluster_boundaries.skip(1);
let mut last_boundary = 0_usize;
let Some(mut current_boundary) = boundaries_iter.next() else {
return; // No clusters
};
fill_cluster_in_place(
&item_text[last_boundary..current_boundary],
&mut item_infos_iter,
&mut code_unit_offset_in_string,
char_cluster,
);
let mut current_font = font_selector.select_font(char_cluster, analysis_data_sources);
// Main segmentation loop (based on swash shape_clusters) - only within current item
while let Some(font) = current_font.take() {
// Collect all clusters for this font segment
let cluster_range = char_cluster.range();
let segment_start_offset = cluster_range.start as usize - text_range.start;
let mut segment_end_offset = cluster_range.end as usize - text_range.start;
for next_boundary in boundaries_iter.by_ref() {
// Build next cluster in-place
last_boundary = current_boundary;
current_boundary = next_boundary;
fill_cluster_in_place(
&item_text[last_boundary..current_boundary],
&mut item_infos_iter,
&mut code_unit_offset_in_string,
char_cluster,
);
if let Some(next_font) = font_selector.select_font(char_cluster, analysis_data_sources)
{
if next_font != font {
current_font = Some(next_font);
break;
} else {
// Same font - add to current segment
segment_end_offset = char_cluster.range().end as usize - text_range.start;
}
} else {
// No font determined, continue to next cluster
continue;
}
}
// Shape this font segment with harfrust
let segment_text = &item_text[segment_start_offset..segment_end_offset];
// Shape the entire segment text including newlines
// The line breaking algorithm will handle newlines automatically
// TODO: How do we want to handle errors like this?
let font_ref =
harfrust::FontRef::from_index(font.font.blob.as_ref(), font.font.index).unwrap();
// Create harfrust shaper
let shaper_data = scx.shape_data_cache.entry(
cache::ShapeDataKey::new(font.font.blob.id(), font.font.index),
|| harfrust::ShaperData::new(&font_ref),
);
let instance = scx.shape_instance_cache.entry(
cache::ShapeInstanceKey::new(
font.font.blob.id(),
font.font.index,
&font.font.synthesis,
rcx.variations(item.variations),
),
|| {
harfrust::ShaperInstance::from_variations(
&font_ref,
variations_iter(&font.font.synthesis, rcx.variations(item.variations)),
)
},
);
let direction = if item.level & 1 != 0 {
harfrust::Direction::RightToLeft
} else {
harfrust::Direction::LeftToRight
};
let hb_script = script_to_harfrust(fb_script);
let language = item
.locale
.as_ref()
.and_then(|lang| lang.language().parse::<harfrust::Language>().ok());
scx.features.clear();
for feature in rcx.features(item.features).unwrap_or(&[]) {
scx.features.push(harfrust::Feature::new(
harfrust::Tag::new(&feature.tag.to_bytes()),
feature.value as u32,
..,
));
}
let harf_shaper = shaper_data
.shaper(&font_ref)
.instance(Some(instance))
.build();
let shaper_plan = scx.shape_plan_cache.entry(
cache::ShapePlanKey::new(
font.font.blob.id(),
font.font.index,
&font.font.synthesis,
direction,
hb_script,
language.clone(),
&scx.features,
rcx.variations(item.variations),
),
|| {
harfrust::ShapePlan::new(
&harf_shaper,
direction,
Some(hb_script),
language.as_ref(),
&scx.features,
)
},
);
// Prepare harfrust buffer
let mut buffer = mem::take(&mut scx.unicode_buffer).unwrap();
buffer.clear();
// Use the entire segment text including newlines
buffer.reserve(segment_text.len());
for (i, ch) in segment_text.chars().enumerate() {
// Ensure that each cluster's index matches the index into `infos`. This is required
// for efficient cluster lookup within `data.rs`.
//
// In other words, instead of using `buffer.push_str`, which iterates `segment_text`
// with `char_indices`, push each char individually via `.chars` with a cluster index
// that matches its `infos` counterpart. This allows us to lookup `infos` via cluster
// index in `data.rs`.
buffer.add(ch, i as u32);
}
buffer.set_direction(direction);
buffer.set_script(hb_script);
if let Some(lang) = language {
buffer.set_language(lang);
}
let glyph_buffer = harf_shaper.shape(
buffer,
ShapeOptions::new()
.plan(Some(shaper_plan))
.features(&scx.features)
.point_size(Some(item.size)),
);
// Extract relevant CharInfo slice for this segment
let char_start = char_range.start + item_text[..segment_start_offset].chars().count();
let segment_char_start = char_start - char_range.start;
let segment_char_count = segment_text.chars().count();
let segment_infos =
&item_infos[segment_char_start..(segment_char_start + segment_char_count)];
// Push harfrust-shaped run for the entire segment
layout.data.push_run(
FontData::new(font.font.blob.clone(), font.font.index),
item.size,
font.attrs,
font.font.synthesis,
&glyph_buffer,
item.level,
item.style_index,
item.word_spacing,
item.letter_spacing,
segment_text,
segment_infos,
(text_range.start + segment_start_offset)..(text_range.start + segment_end_offset),
harf_shaper.coords(),
);
// Replace buffer to reuse allocation in next iteration.
scx.unicode_buffer = Some(glyph_buffer.clear());
}
}
fn real_script(script: Script) -> bool {
script != Script::Common && script != Script::Unknown && script != Script::Inherited
}
fn variations_iter<'a>(
synthesis: &'a fontique::Synthesis,
item: Option<&'a [FontVariation]>,
) -> impl Iterator<Item = harfrust::Variation> + 'a {
synthesis
.variation_settings()
.iter()
.map(|(tag, value)| harfrust::Variation {
tag: *tag,
value: *value,
})
.chain(
item.unwrap_or(&[])
.iter()
.map(|variation| harfrust::Variation {
tag: harfrust::Tag::new(&variation.tag.to_bytes()),
value: variation.value,
}),
)
}
struct FontSelector<'a, 'b, B: Brush> {
query: &'b mut Query<'a>,
fonts_id: Option<usize>,
rcx: &'a ResolveContext,
styles: &'a [ResolvedStyle<B>],
style_index: u16,
attrs: fontique::Attributes,
variations: &'a [FontVariation],
features: &'a [FontFeature],
}
impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> {
fn new(
query: &'b mut Query<'a>,
rcx: &'a ResolveContext,
styles: &'a [ResolvedStyle<B>],
style_index: u16,
fb_script: fontique::Script,
locale: Option<Language>,
) -> Self {
let style = &styles[style_index as usize];
let fonts_id = style.font_family.id();
let fonts = rcx.stack(style.font_family).unwrap_or(&[]);
let attrs = fontique::Attributes {
width: style.font_width,
weight: style.font_weight,
style: style.font_style,
};
let variations = rcx.variations(style.font_variations).unwrap_or(&[]);
let features = rcx.features(style.font_features).unwrap_or(&[]);
query.set_families(fonts.iter().copied());
query.set_fallbacks(fontique::FallbackKey::new(fb_script, locale.as_ref()));
query.set_attributes(attrs);
Self {
query,
fonts_id: Some(fonts_id),
rcx,
styles,
style_index,
attrs,
variations,
features,
}
}
fn select_font(
&mut self,
cluster: &mut CharCluster,
analysis_data_sources: &AnalysisDataSources,
) -> Option<SelectedFont> {
let style_index = cluster.style_index();
let is_emoji = cluster.is_emoji;
if style_index != self.style_index || is_emoji || self.fonts_id.is_none() {
self.style_index = style_index;
let style = &self.styles[style_index as usize];
let fonts_id = style.font_family.id();
let fonts = self.rcx.stack(style.font_family).unwrap_or(&[]);
let fonts = fonts.iter().copied().map(QueryFamily::Id);
if is_emoji {
use core::iter::once;
let emoji_family = QueryFamily::Generic(fontique::GenericFamily::Emoji);
self.query.set_families(fonts.chain(once(emoji_family)));
self.fonts_id = None;
} else if self.fonts_id != Some(fonts_id) {
self.query.set_families(fonts);
self.fonts_id = Some(fonts_id);
}
let attrs = fontique::Attributes {
width: style.font_width,
weight: style.font_weight,
style: style.font_style,
};
if self.attrs != attrs {
self.query.set_attributes(attrs);
self.attrs = attrs;
}
self.variations = self.rcx.variations(style.font_variations).unwrap_or(&[]);
self.features = self.rcx.features(style.font_features).unwrap_or(&[]);
}
let mut selected_font = None;
self.query.matches_with(|font| {
let Some(charmap) = font.charmap() else {
return fontique::QueryStatus::Continue;
};
let map_status = cluster.map(
|ch| {
charmap
.map(ch)
.map(|g| {
// HACK: in reality, we're only computing coverage, so
// we only care about whether the font has a mapping
// for a particular glyph. Any non-zero value indicates
// the existence of a glyph so we can simplify this
// without a fallible conversion from u32 to u16.
(g != 0) as u16
})
.unwrap_or_default()
},
analysis_data_sources,
);
match map_status {
Status::Complete => {
selected_font = Some(SelectedFont {
font: font.clone(),
attrs: self.attrs,
});
fontique::QueryStatus::Stop
}
Status::Keep => {
selected_font = Some(SelectedFont {
font: font.clone(),
attrs: self.attrs,
});
fontique::QueryStatus::Continue
}
Status::Discard => {
if selected_font.is_none() {
selected_font = Some(SelectedFont {
font: font.clone(),
attrs: self.attrs,
});
}
fontique::QueryStatus::Continue
}
}
});
selected_font
}
}
struct SelectedFont {
font: QueryFont,
attrs: fontique::Attributes,
}
impl PartialEq for SelectedFont {
fn eq(&self, other: &Self) -> bool {
self.font.family == other.font.family && self.font.synthesis == other.font.synthesis
}
}
+7
View File
@@ -0,0 +1,7 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
/// Trait for types that represent the color of glyphs or decorations.
pub trait Brush: Clone + PartialEq + Default + core::fmt::Debug {}
impl<T: Clone + PartialEq + Default + core::fmt::Debug> Brush for T {}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use alloc::borrow::Cow;
pub use crate::setting::{FontFeature, FontVariation};
pub use fontique::{FontStyle, FontWeight, FontWidth, GenericFamily};
pub use parlance::{FontFamily, FontFamilyName};
/// Font variation settings that can be supplied as a raw source string or a parsed slice.
#[derive(Clone, PartialEq, Debug)]
pub enum FontVariations<'a> {
/// Setting source in CSS format.
Source(Cow<'a, str>),
/// List of settings.
List(Cow<'a, [FontVariation]>),
}
impl<'a> FontVariations<'a> {
/// Creates an empty list of font variations.
pub const fn empty() -> Self {
Self::List(Cow::Borrowed(&[]))
}
}
impl<'a> From<&'a str> for FontVariations<'a> {
fn from(value: &'a str) -> Self {
Self::Source(Cow::Borrowed(value))
}
}
impl<'a> From<&'a [FontVariation]> for FontVariations<'a> {
fn from(value: &'a [FontVariation]) -> Self {
Self::List(Cow::Borrowed(value))
}
}
impl<'a, const N: usize> From<&'a [FontVariation; N]> for FontVariations<'a> {
fn from(value: &'a [FontVariation; N]) -> Self {
Self::List(Cow::Borrowed(&value[..]))
}
}
/// Font feature settings that can be supplied as a raw source string or a parsed slice.
#[derive(Clone, PartialEq, Debug)]
pub enum FontFeatures<'a> {
/// Setting source in CSS format.
Source(Cow<'a, str>),
/// List of settings.
List(Cow<'a, [FontFeature]>),
}
impl<'a> FontFeatures<'a> {
/// Creates an empty list of font features.
pub const fn empty() -> Self {
Self::List(Cow::Borrowed(&[]))
}
}
impl<'a> From<&'a str> for FontFeatures<'a> {
fn from(value: &'a str) -> Self {
Self::Source(Cow::Borrowed(value))
}
}
impl<'a> From<&'a [FontFeature]> for FontFeatures<'a> {
fn from(value: &'a [FontFeature]) -> Self {
Self::List(Cow::Borrowed(value))
}
}
impl<'a, const N: usize> From<&'a [FontFeature; N]> for FontFeatures<'a> {
fn from(value: &'a [FontFeature; N]) -> Self {
Self::List(Cow::Borrowed(&value[..]))
}
}
+279
View File
@@ -0,0 +1,279 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Rich styling support.
mod brush;
mod font;
mod styleset;
use alloc::borrow::Cow;
pub use brush::*;
pub use font::{
FontFamily, FontFamilyName, FontFeature, FontFeatures, FontStyle, FontVariation,
FontVariations, FontWeight, FontWidth, GenericFamily,
};
pub use fontique::Language;
pub use parlance::{OverflowWrap, TextWrapMode, WordBreak};
pub use styleset::StyleSet;
use crate::util::nearly_eq;
#[derive(Debug, Clone, Copy)]
pub enum WhiteSpaceCollapse {
Collapse,
Preserve,
}
/// The height that this text takes up. The default is `MetricsRelative(1.0)`, which is the given
/// font's preferred line height.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LineHeight {
/// The line's height is a multiple of the "line height" defined by the font's metrics--the sum
/// of the ascender height, descender height, and line gap / leading.
MetricsRelative(f32),
/// Line height specified as a multiple of the font size. This is how the CSS `line-height`
/// property behaves if given a unitless number. Useful if you're using system-defined generic
/// font families and want the line heights to be consistent across platforms.
FontSizeRelative(f32),
/// Line height specified in absolute units. This can be useful for ensuring all lines are
/// spaced a whole number of pixels apart, or fitting lines into a given layout container
/// height.
Absolute(f32),
}
impl Default for LineHeight {
fn default() -> Self {
Self::MetricsRelative(1.0)
}
}
impl LineHeight {
pub(crate) fn nearly_eq(self, other: Self) -> bool {
match (self, other) {
(Self::MetricsRelative(a), Self::MetricsRelative(b))
| (Self::FontSizeRelative(a), Self::FontSizeRelative(b))
| (Self::Absolute(a), Self::Absolute(b)) => nearly_eq(a, b),
_ => false,
}
}
pub(crate) fn scale(self, scale: f32) -> Self {
match self {
Self::Absolute(value) => Self::Absolute(value * scale),
// The other variants are relative to the font size, so scaling here needn't do anything
value => value,
}
}
}
/// Properties that define a style.
#[derive(Clone, PartialEq, Debug)]
pub enum StyleProperty<'a, B: Brush> {
/// CSS `font-family` property value.
FontFamily(FontFamily<'a>),
/// Font size.
FontSize(f32),
/// Font width.
FontWidth(FontWidth),
/// Font style.
FontStyle(FontStyle),
/// Font weight.
FontWeight(FontWeight),
/// Font variation settings.
FontVariations(FontVariations<'a>),
/// Font feature settings.
FontFeatures(FontFeatures<'a>),
/// Locale.
Locale(Option<Language>),
/// Brush for rendering text.
Brush(B),
/// Underline decoration.
Underline(bool),
/// Offset of the underline decoration.
UnderlineOffset(Option<f32>),
/// Size of the underline decoration.
UnderlineSize(Option<f32>),
/// Brush for rendering the underline decoration.
UnderlineBrush(Option<B>),
/// Strikethrough decoration.
Strikethrough(bool),
/// Offset of the strikethrough decoration.
StrikethroughOffset(Option<f32>),
/// Size of the strikethrough decoration.
StrikethroughSize(Option<f32>),
/// Brush for rendering the strikethrough decoration.
StrikethroughBrush(Option<B>),
/// Line height.
LineHeight(LineHeight),
/// Extra spacing between words.
WordSpacing(f32),
/// Extra spacing between letters.
LetterSpacing(f32),
/// Control over where words can wrap.
WordBreak(WordBreak),
/// Control over "emergency" line-breaking.
OverflowWrap(OverflowWrap),
/// Control over non-"emergency" line-breaking.
TextWrapMode(TextWrapMode),
}
/// Unresolved styles.
#[derive(Clone, PartialEq, Debug)]
pub struct TextStyle<'family, 'settings, B: Brush> {
/// CSS `font-family` property value.
pub font_family: FontFamily<'family>,
/// Font size.
pub font_size: f32,
/// Font width.
pub font_width: FontWidth,
/// Font style.
pub font_style: FontStyle,
/// Font weight.
pub font_weight: FontWeight,
/// Font variation settings.
pub font_variations: FontVariations<'settings>,
/// Font feature settings.
pub font_features: FontFeatures<'settings>,
/// Locale.
pub locale: Option<Language>,
/// Brush for rendering text.
pub brush: B,
/// Underline decoration.
pub has_underline: bool,
/// Offset of the underline decoration.
pub underline_offset: Option<f32>,
/// Size of the underline decoration.
pub underline_size: Option<f32>,
/// Brush for rendering the underline decoration.
pub underline_brush: Option<B>,
/// Strikethrough decoration.
pub has_strikethrough: bool,
/// Offset of the strikethrough decoration.
pub strikethrough_offset: Option<f32>,
/// Size of the strikethrough decoration.
pub strikethrough_size: Option<f32>,
/// Brush for rendering the strikethrough decoration.
pub strikethrough_brush: Option<B>,
/// Line height.
pub line_height: LineHeight,
/// Extra spacing between words.
pub word_spacing: f32,
/// Extra spacing between letters.
pub letter_spacing: f32,
/// Control over where words can wrap.
pub word_break: WordBreak,
/// Control over "emergency" line-breaking.
pub overflow_wrap: OverflowWrap,
/// Control over non-"emergency" line-breaking.
pub text_wrap_mode: TextWrapMode,
}
impl<B: Brush> Default for TextStyle<'static, 'static, B> {
fn default() -> Self {
TextStyle {
font_family: FontFamily::Source(Cow::Borrowed("sans-serif")),
font_size: 16.0,
font_width: FontWidth::default(),
font_style: FontStyle::default(),
font_weight: FontWeight::default(),
font_variations: FontVariations::empty(),
font_features: FontFeatures::empty(),
locale: None,
brush: B::default(),
has_underline: false,
underline_offset: None,
underline_size: None,
underline_brush: None,
has_strikethrough: false,
strikethrough_offset: None,
strikethrough_size: None,
strikethrough_brush: None,
line_height: LineHeight::default(),
word_spacing: 0.0,
letter_spacing: 0.0,
word_break: WordBreak::default(),
overflow_wrap: OverflowWrap::default(),
text_wrap_mode: TextWrapMode::default(),
}
}
}
impl<'a, B: Brush> From<FontFamily<'a>> for StyleProperty<'a, B> {
fn from(value: FontFamily<'a>) -> Self {
StyleProperty::FontFamily(value)
}
}
impl<'a, B: Brush> From<&'a [FontFamilyName<'a>]> for StyleProperty<'a, B> {
fn from(value: &'a [FontFamilyName<'a>]) -> Self {
StyleProperty::FontFamily(value.into())
}
}
impl<'a, B: Brush> From<FontFamilyName<'a>> for StyleProperty<'a, B> {
fn from(value: FontFamilyName<'a>) -> Self {
StyleProperty::FontFamily(value.into())
}
}
impl<'a, B: Brush> From<FontVariations<'a>> for StyleProperty<'a, B> {
fn from(value: FontVariations<'a>) -> Self {
StyleProperty::FontVariations(value)
}
}
impl<'a, B: Brush> From<FontFeatures<'a>> for StyleProperty<'a, B> {
fn from(value: FontFeatures<'a>) -> Self {
StyleProperty::FontFeatures(value)
}
}
impl<B: Brush> From<GenericFamily> for StyleProperty<'_, B> {
fn from(f: GenericFamily) -> Self {
StyleProperty::FontFamily(f.into())
}
}
impl<B: Brush> From<LineHeight> for StyleProperty<'_, B> {
fn from(value: LineHeight) -> Self {
StyleProperty::LineHeight(value)
}
}
impl<B: Brush> From<FontWidth> for StyleProperty<'_, B> {
fn from(value: FontWidth) -> Self {
StyleProperty::FontWidth(value)
}
}
impl<B: Brush> From<FontStyle> for StyleProperty<'_, B> {
fn from(value: FontStyle) -> Self {
StyleProperty::FontStyle(value)
}
}
impl<B: Brush> From<FontWeight> for StyleProperty<'_, B> {
fn from(value: FontWeight) -> Self {
StyleProperty::FontWeight(value)
}
}
impl<B: Brush> From<WordBreak> for StyleProperty<'_, B> {
fn from(value: WordBreak) -> Self {
StyleProperty::WordBreak(value)
}
}
impl<B: Brush> From<OverflowWrap> for StyleProperty<'_, B> {
fn from(value: OverflowWrap) -> Self {
StyleProperty::OverflowWrap(value)
}
}
impl<B: Brush> From<TextWrapMode> for StyleProperty<'_, B> {
fn from(value: TextWrapMode) -> Self {
StyleProperty::TextWrapMode(value)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2024 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use core::mem::Discriminant;
use hashbrown::HashMap;
type StyleProperty<Brush> = crate::StyleProperty<'static, Brush>;
/// A long-lived collection of [`StyleProperties`](super::StyleProperty), containing at
/// most one of each property.
///
/// This is used by [`PlainEditor`](crate::editing::PlainEditor) to provide a reasonably ergonomic
/// mutable API for styles applied to all text managed by it.
/// This can be accessed using [`PlainEditor::edit_styles`](crate::editing::PlainEditor::edit_styles).
///
/// These styles do not have a corresponding range, and are generally unsuited for rich text.
#[derive(Clone, Debug)]
pub struct StyleSet<Brush: crate::Brush>(
HashMap<Discriminant<StyleProperty<Brush>>, StyleProperty<Brush>>,
);
impl<Brush: crate::Brush> StyleSet<Brush> {
/// Create a new collection of styles.
///
/// The font size will be `font_size`, and can be overwritten at runtime by
/// [inserting](Self::insert) a new [`FontSize`](crate::StyleProperty::FontSize).
pub fn new(font_size: f32) -> Self {
let mut this = Self(HashMap::default());
this.insert(StyleProperty::FontSize(font_size));
this
}
/// Add `style` to this collection, returning any overwritten value.
///
/// Note: Adding a [`font-family`](crate::StyleProperty::FontFamily) to this collection is not
/// additive, and instead overwrites any previously added font stack.
pub fn insert(&mut self, style: StyleProperty<Brush>) -> Option<StyleProperty<Brush>> {
let discriminant = core::mem::discriminant(&style);
self.0.insert(discriminant, style)
}
/// [Retain](alloc::vec::Vec::retain) only the styles for which `f` returns true.
///
/// Styles which are removed return to their default values.
///
/// Removing the [font size](crate::StyleProperty::FontSize) is not recommended, as an unspecified
/// fallback font size will be used.
pub fn retain(&mut self, mut f: impl FnMut(&StyleProperty<Brush>) -> bool) {
self.0.retain(|_, v| f(v));
}
/// Remove the style with the discriminant `property`.
///
/// Styles which are removed return to their default values.
///
/// To get the discriminant requires constructing a valid `StyleProperty` for the
/// the desired property and passing it to [`core::mem::discriminant`].
/// Getting this discriminant is usually possible in a `const` context.
///
/// Removing the [font size](crate::StyleProperty::FontSize) is not recommended, as an unspecified
/// fallback font size will be used.
pub fn remove(
&mut self,
property: Discriminant<StyleProperty<Brush>>,
) -> Option<StyleProperty<Brush>> {
self.0.remove(&property)
}
/// Read the raw underlying storage of this.
///
/// Write access is not provided due to the invariant that keys
/// are the discriminant of their corresponding value.
pub fn inner(&self) -> &HashMap<Discriminant<StyleProperty<Brush>>, StyleProperty<Brush>> {
&self.0
}
/// Clear all styles.
///
/// All styles return to their default values.
pub fn clear(&mut self) {
self.0.clear();
}
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright 2024 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
mod test_analysis;
mod test_builders;
mod utils;
File diff suppressed because it is too large Load Diff
+546
View File
@@ -0,0 +1,546 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Test that the various builders produce the same results.
use std::{borrow::Cow, path::PathBuf, sync::Arc};
use fontique::{Collection, CollectionOptions, FontStyle, FontWeight, FontWidth, SourceCache};
use parlance::FontFamilyName;
use peniko::{Blob, color::palette};
use super::utils::{ColorBrush, asserts::assert_eq_layout_data};
use crate::{
FontContext, FontFamily, FontFeatures, FontVariations, Layout, LayoutContext, LineHeight,
OverflowWrap, RangedBuilder, StyleProperty, StyleRunBuilder, TextStyle, TextWrapMode,
TreeBuilder, WordBreak,
};
// TODO: `FONT_FAMILY_LIST`, `load_fonts`, and `create_font_context` are
// duplicated between this crate and `parley_test`. We can't move the builder
// tests into `parley_test` because they use private APIs, but should eventually
// figure out some way to reduce the duplication.
const FONT_FAMILY_LIST: &[FontFamilyName<'_>] = &[
FontFamilyName::Named(Cow::Borrowed("Roboto")),
FontFamilyName::Named(Cow::Borrowed("Noto Kufi Arabic")),
];
pub(crate) fn load_fonts(
collection: &mut Collection,
font_dirs: impl Iterator<Item = PathBuf>,
) -> std::io::Result<()> {
for dir in font_dirs {
let paths = std::fs::read_dir(dir)?;
for entry in paths {
let entry = entry?;
if !entry.metadata()?.is_file() {
continue;
}
let path = entry.path();
if path
.extension()
.and_then(|ext| ext.to_str())
.is_none_or(|ext| !["ttf", "otf", "ttc", "otc"].contains(&ext))
{
continue;
}
let font_data = std::fs::read(&path)?;
collection.register_fonts(Blob::new(Arc::new(font_data)), None);
}
}
Ok(())
}
fn create_font_context() -> FontContext {
let mut collection = Collection::new(CollectionOptions {
shared: false,
system_fonts: false,
});
load_fonts(&mut collection, parley_dev::font_dirs()).unwrap();
for font in FONT_FAMILY_LIST {
if let FontFamilyName::Named(font_name) = font {
collection
.family_id(font_name)
.unwrap_or_else(|| panic!("{font_name} font not found"));
}
}
FontContext {
collection,
source_cache: SourceCache::default(),
}
}
/// Set of options for [`build_layout_with_ranged`].
struct RangedOptions<'a> {
scale: f32,
quantize: bool,
max_advance: Option<f32>,
text: &'a str,
}
/// Set of options for [`build_layout_with_tree`].
struct TreeOptions<'a, 'b> {
scale: f32,
quantize: bool,
max_advance: Option<f32>,
root_style: &'a TextStyle<'b, 'b, ColorBrush>,
}
/// Generates a `Layout` with a ranged builder.
fn build_layout_with_ranged(
fcx: &mut FontContext,
lcx: &mut LayoutContext<ColorBrush>,
opts: &RangedOptions<'_>,
with_builder: impl Fn(&mut RangedBuilder<'_, ColorBrush>),
) -> Layout<ColorBrush> {
let mut rb = lcx.ranged_builder(fcx, opts.text, opts.scale, opts.quantize);
with_builder(&mut rb);
let mut layout = rb.build(opts.text);
layout.break_all_lines(opts.max_advance);
layout
}
/// Generates a `Layout` with a tree builder.
fn build_layout_with_tree(
fcx: &mut FontContext,
lcx: &mut LayoutContext<ColorBrush>,
opts: &TreeOptions<'_, '_>,
with_builder: impl Fn(&mut TreeBuilder<'_, ColorBrush>),
) -> Layout<ColorBrush> {
let mut tb = lcx.tree_builder(fcx, opts.scale, opts.quantize, opts.root_style);
with_builder(&mut tb);
let (mut layout, _) = tb.build();
layout.break_all_lines(opts.max_advance);
layout
}
/// Generates a `Layout` with a style run builder.
fn build_layout_with_style_runs(
fcx: &mut FontContext,
lcx: &mut LayoutContext<ColorBrush>,
opts: &RangedOptions<'_>,
with_builder: impl Fn(&mut StyleRunBuilder<'_, ColorBrush>),
) -> Layout<ColorBrush> {
let mut rb = lcx.style_run_builder(fcx, opts.text, opts.scale, opts.quantize);
with_builder(&mut rb);
let mut layout = rb.build(opts.text);
layout.break_all_lines(opts.max_advance);
layout
}
/// Computes layout in various ways to ensure they all produce the same result.
///
/// ```text
/// LayoutContext A - Ranged
/// LayoutContext A - Ranged for idempotency
///
/// LayoutContext B - Tree
/// LayoutContext B - Tree for idempotency
///
/// LayoutContext C - Ranged for dirt
/// LayoutContext C - Tree from dirty
///
/// LayoutContext D - Tree for dirt
/// LayoutContext D - Ranged from dirty
/// ```
fn assert_builders_produce_same_result<'b>(
text: &str,
scale: f32,
quantize: bool,
max_advance: Option<f32>,
root_style: &TextStyle<'b, 'b, ColorBrush>,
with_ranged_builder: impl Fn(&mut RangedBuilder<'_, ColorBrush>),
with_tree_builder: impl Fn(&mut TreeBuilder<'_, ColorBrush>),
expect_empty: bool,
) {
let mut fcx = create_font_context();
let mut lcx_a: LayoutContext<ColorBrush> = LayoutContext::new();
let mut lcx_b: LayoutContext<ColorBrush> = LayoutContext::new();
let mut lcx_c: LayoutContext<ColorBrush> = LayoutContext::new();
let mut lcx_d: LayoutContext<ColorBrush> = LayoutContext::new();
let ropts = RangedOptions {
scale,
quantize,
max_advance,
text,
};
let topts = TreeOptions {
scale,
quantize,
max_advance,
root_style,
};
// Source of truth - ranged builder from a clean layout context
let layout_truth = build_layout_with_ranged(&mut fcx, &mut lcx_a, &ropts, &with_ranged_builder);
assert!(
layout_truth.data.runs.is_empty() == expect_empty,
"expected runs to exist for lcx_a_rb_one"
);
// Testing idempotence of ranged builder creation
let layout = build_layout_with_ranged(&mut fcx, &mut lcx_a, &ropts, &with_ranged_builder);
assert!(
layout.data.runs.is_empty() == expect_empty,
"expected runs to exist for lcx_a_rb_two"
);
assert_eq_layout_data(&layout_truth.data, &layout.data, "lcx_a_rb_two");
// Basic builder compatibility - tree builder from a clean layout context
let layout = build_layout_with_tree(&mut fcx, &mut lcx_b, &topts, &with_tree_builder);
assert!(
layout.data.runs.is_empty() == expect_empty,
"expected runs to exist for lcx_b_tb_one"
);
assert_eq_layout_data(&layout_truth.data, &layout.data, "lcx_b_tb_one");
// Testing idempotence of tree builder creation
let layout = build_layout_with_tree(&mut fcx, &mut lcx_b, &topts, &with_tree_builder);
assert!(
layout.data.runs.is_empty() == expect_empty,
"expected runs to exist for lcx_b_tb_two"
);
assert_eq_layout_data(&layout_truth.data, &layout.data, "lcx_b_tb_two");
// Priming a fresh layout context with ranged builder creation
let _ = build_layout_with_ranged(&mut fcx, &mut lcx_c, &ropts, &with_ranged_builder);
// Testing tree builder creation with a dirty layout context
let layout = build_layout_with_tree(&mut fcx, &mut lcx_c, &topts, &with_tree_builder);
assert!(
layout.data.runs.is_empty() == expect_empty,
"expected runs to exist for lcx_c_tb_one"
);
assert_eq_layout_data(&layout_truth.data, &layout.data, "lcx_c_tb_one");
// Priming a fresh layout context with tree builder creation
let _ = build_layout_with_tree(&mut fcx, &mut lcx_d, &topts, &with_tree_builder);
// Testing ranged builder creation with a dirty layout context
let layout = build_layout_with_ranged(&mut fcx, &mut lcx_d, &ropts, &with_ranged_builder);
assert!(
layout.data.runs.is_empty() == expect_empty,
"expected runs to exist for lcx_d_rb_one"
);
assert_eq_layout_data(&layout_truth.data, &layout.data, "lcx_d_rb_one");
}
/// Returns a root style that uses non-default values.
///
/// The [`TreeBuilder`] version of [`set_root_style`].
fn create_root_style() -> TextStyle<'static, 'static, ColorBrush> {
TextStyle {
font_family: FontFamily::from(FONT_FAMILY_LIST),
font_size: 20.,
font_width: FontWidth::CONDENSED,
font_style: FontStyle::Italic,
font_weight: FontWeight::BOLD,
font_variations: FontVariations::empty(), // TODO: Set a non-default value
font_features: FontFeatures::empty(), // TODO: Set a non-default value
locale: Some("en-US".parse().unwrap()),
brush: ColorBrush::new(palette::css::GREEN),
has_underline: true,
underline_offset: Some(2.),
underline_size: Some(3.5),
underline_brush: Some(ColorBrush::new(palette::css::CYAN)),
has_strikethrough: true,
strikethrough_offset: Some(1.3),
strikethrough_size: Some(1.7),
strikethrough_brush: Some(ColorBrush::new(palette::css::BEIGE)),
line_height: LineHeight::Absolute(30.),
word_spacing: 2.,
letter_spacing: 1.5,
word_break: WordBreak::BreakAll,
overflow_wrap: OverflowWrap::Anywhere,
text_wrap_mode: TextWrapMode::Wrap,
}
}
/// Sets a root style with non-default values.
///
/// The [`RangedBuilder`] version of [`create_root_style`].
fn set_root_style(rb: &mut RangedBuilder<'_, ColorBrush>) {
rb.push_default(FontFamily::from(FONT_FAMILY_LIST));
rb.push_default(StyleProperty::FontSize(20.));
rb.push_default(StyleProperty::FontWidth(FontWidth::CONDENSED));
rb.push_default(StyleProperty::FontStyle(FontStyle::Italic));
rb.push_default(StyleProperty::FontWeight(FontWeight::BOLD));
rb.push_default(FontVariations::empty());
rb.push_default(FontFeatures::empty());
rb.push_default(StyleProperty::Locale(Some("en-US".parse().unwrap())));
rb.push_default(StyleProperty::Brush(ColorBrush::new(palette::css::GREEN)));
rb.push_default(StyleProperty::Underline(true));
rb.push_default(StyleProperty::UnderlineOffset(Some(2.)));
rb.push_default(StyleProperty::UnderlineSize(Some(3.5)));
rb.push_default(StyleProperty::UnderlineBrush(Some(ColorBrush::new(
palette::css::CYAN,
))));
rb.push_default(StyleProperty::Strikethrough(true));
rb.push_default(StyleProperty::StrikethroughOffset(Some(1.3)));
rb.push_default(StyleProperty::StrikethroughSize(Some(1.7)));
rb.push_default(StyleProperty::StrikethroughBrush(Some(ColorBrush::new(
palette::css::BEIGE,
))));
rb.push_default(LineHeight::Absolute(30.));
rb.push_default(StyleProperty::WordSpacing(2.));
rb.push_default(StyleProperty::LetterSpacing(1.5));
rb.push_default(StyleProperty::WordBreak(WordBreak::BreakAll));
rb.push_default(StyleProperty::OverflowWrap(OverflowWrap::Anywhere));
}
/// Test that all the builders have the same default behavior.
#[test]
fn builders_default() {
let text = "Builders often wear hard hats for safety while working on construction sites.";
let scale = 2.;
let quantize = false;
let max_advance = Some(50.);
let root_style = TextStyle {
font_family: FontFamily::from(FONT_FAMILY_LIST),
..TextStyle::default()
};
let with_ranged_builder = |rb: &mut RangedBuilder<'_, ColorBrush>| {
rb.push_default(FontFamily::from(FONT_FAMILY_LIST));
};
let with_tree_builder = |tb: &mut TreeBuilder<'_, ColorBrush>| {
tb.push_text(text);
};
assert_builders_produce_same_result(
text,
scale,
quantize,
max_advance,
&root_style,
with_ranged_builder,
with_tree_builder,
false,
);
}
/// Test that `StyleRunBuilder` produces the same result as `RangedBuilder` when given equivalent
/// styles.
#[test]
fn builders_style_runs_match_ranged() {
let text = "Builders often wear hard hats.";
let scale = 2.;
let quantize = false;
let max_advance = Some(120.);
let root_style: TextStyle<'static, 'static, ColorBrush> = TextStyle {
font_family: FontFamily::from(FONT_FAMILY_LIST),
..TextStyle::default()
};
let split = text.len() / 2;
let mut modified_style = root_style.clone();
modified_style.font_size = 40.;
modified_style.letter_spacing = 1.25;
let mut fcx = create_font_context();
let mut lcx_a: LayoutContext<ColorBrush> = LayoutContext::new();
let mut lcx_b: LayoutContext<ColorBrush> = LayoutContext::new();
let ropts = RangedOptions {
scale,
quantize,
max_advance,
text,
};
let ranged = build_layout_with_ranged(&mut fcx, &mut lcx_a, &ropts, |rb| {
rb.push_default(FontFamily::from(FONT_FAMILY_LIST));
rb.push(
StyleProperty::FontSize(modified_style.font_size),
split..text.len(),
);
rb.push(
StyleProperty::LetterSpacing(modified_style.letter_spacing),
split..text.len(),
);
});
let runs = build_layout_with_style_runs(&mut fcx, &mut lcx_b, &ropts, |rb| {
let family: FontFamily<'static> = root_style.font_family.clone().into_owned();
let root_run: TextStyle<'static, 'static, ColorBrush> = TextStyle {
font_family: family.clone(),
..root_style.clone()
};
let modified_run: TextStyle<'static, 'static, ColorBrush> = TextStyle {
font_family: family,
..modified_style.clone()
};
let root_index = rb.push_style(root_run);
let modified_index = rb.push_style(modified_run);
rb.push_style_run(root_index, 0..split);
rb.push_style_run(modified_index, split..text.len());
});
assert_eq_layout_data(&ranged.data, &runs.data, "style_runs_match_ranged");
}
/// Test that `StyleRunBuilder` handles a first run whose style table index is not zero.
#[test]
fn style_runs_first_run_can_use_nonzero_style_index() {
let text = "Builders often wear hard hats.";
let scale = 2.;
let quantize = false;
let max_advance = Some(50.);
let root_style = create_root_style();
let mut modified_style = root_style.clone();
modified_style.font_size = 40.;
let mut fcx = create_font_context();
let mut lcx_a: LayoutContext<ColorBrush> = LayoutContext::new();
let mut lcx_b: LayoutContext<ColorBrush> = LayoutContext::new();
let ropts = RangedOptions {
scale,
quantize,
max_advance,
text,
};
let ranged = build_layout_with_ranged(&mut fcx, &mut lcx_a, &ropts, |rb| {
set_root_style(rb);
rb.push(
StyleProperty::FontSize(modified_style.font_size),
0..text.len(),
);
});
let runs = build_layout_with_style_runs(&mut fcx, &mut lcx_b, &ropts, |rb| {
let family: FontFamily<'static> = root_style.font_family.clone().into_owned();
let root_run: TextStyle<'static, 'static, ColorBrush> = TextStyle {
font_family: family.clone(),
..root_style.clone()
};
let modified_run: TextStyle<'static, 'static, ColorBrush> = TextStyle {
font_family: family,
..modified_style.clone()
};
let _root_index = rb.push_style(root_run);
let modified_index = rb.push_style(modified_run);
rb.push_style_run(modified_index, 0..text.len());
});
assert_eq_layout_data(
&ranged.data,
&runs.data,
"style_runs_first_run_can_use_nonzero_style_index",
);
}
/// Test that all the builders behave the same when given the same root style.
#[test]
fn builders_root_only() {
let text = "Builders often wear hard hats for safety while working on construction sites.";
let scale = 2.;
let quantize = false;
let max_advance = Some(50.);
let root_style = create_root_style();
let with_ranged_builder = |rb: &mut RangedBuilder<'_, ColorBrush>| {
set_root_style(rb);
};
let with_tree_builder = |tb: &mut TreeBuilder<'_, ColorBrush>| {
tb.push_text(text);
};
assert_builders_produce_same_result(
text,
scale,
quantize,
max_advance,
&root_style,
with_ranged_builder,
with_tree_builder,
false,
);
}
/// Test that an empty layout doesn't crash
#[test]
fn builders_empty() {
let text = "";
let scale = 1.;
let quantize = false;
let max_advance = Some(50.);
let root_style = create_root_style();
let with_ranged_builder = |_rb: &mut RangedBuilder<'_, ColorBrush>| {};
let with_tree_builder = |_tb: &mut TreeBuilder<'_, ColorBrush>| {};
assert_builders_produce_same_result(
text,
scale,
quantize,
max_advance,
&root_style,
with_ranged_builder,
with_tree_builder,
true,
);
}
/// Test that all the builders behave the same with mixed styles.
#[test]
fn builders_mixed_styles() {
let text = "Builders often wear hard hats for safety while working on construction sites.";
let scale = 2.;
let quantize = false;
let max_advance = Some(50.);
let root_style = create_root_style();
let with_ranged_builder = |rb: &mut RangedBuilder<'_, ColorBrush>| {
set_root_style(rb);
// Make the first word bigger
rb.push(StyleProperty::FontSize(68.), 0..8);
// Push two modified styles for the same range
rb.push(StyleProperty::LetterSpacing(4.), 12..17);
rb.push(StyleProperty::WordSpacing(3.), 12..17);
// Plus, change the line height for the last letter
rb.push(StyleProperty::LineHeight(LineHeight::Absolute(40.)), 16..17);
};
let with_tree_builder = |tb: &mut TreeBuilder<'_, ColorBrush>| {
// Make the first word bigger
tb.push_style_modification_span(&[StyleProperty::FontSize(68.)]);
tb.push_text(&text[..8]);
tb.pop_style_span();
tb.push_text(&text[8..12]);
// Push two modified styles in batch
tb.push_style_modification_span(&[
StyleProperty::LetterSpacing(4.),
StyleProperty::WordSpacing(3.),
]);
tb.push_text(&text[12..16]);
// Plus, change the line height for the last letter
tb.push_style_modification_span(&[StyleProperty::LineHeight(LineHeight::Absolute(40.))]);
tb.push_text(&text[16..17]);
tb.pop_style_span();
tb.pop_style_span();
tb.push_text(&text[17..]);
};
assert_builders_produce_same_result(
text,
scale,
quantize,
max_advance,
&root_style,
with_ranged_builder,
with_tree_builder,
false,
);
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2025 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Various helper functions to assert truths during testing.
use std::vec::Vec;
use crate::{Brush, data::LayoutData};
fn canonicalize_layout_data<B: Brush>(layout_data: &LayoutData<B>) -> LayoutData<B> {
let mut normalized = layout_data.clone();
let mut canonical_styles = Vec::with_capacity(normalized.styles.len());
let mut remap = Vec::with_capacity(normalized.styles.len());
for style in &normalized.styles {
if let Some(index) = canonical_styles
.iter()
.position(|existing| existing == style)
{
remap.push(index as u16);
} else {
let index = canonical_styles.len() as u16;
canonical_styles.push(style.clone());
remap.push(index);
}
}
for cluster in &mut normalized.clusters {
cluster.style_index = remap[cluster.style_index as usize];
}
for glyph in &mut normalized.glyphs {
glyph.style_index = remap[glyph.style_index as usize];
}
normalized.styles = canonical_styles;
normalized
}
/// Assert that the two provided `LayoutData` are equal.
pub(crate) fn assert_eq_layout_data<B: Brush>(a: &LayoutData<B>, b: &LayoutData<B>, case: &str) {
let a = canonicalize_layout_data(a);
let b = canonicalize_layout_data(b);
assert_eq!(a.scale, b.scale, "{case} scale mismatch");
assert_eq!(a.quantize, b.quantize, "{case} quantize mismatch");
assert_eq!(a.base_level, b.base_level, "{case} base_level mismatch");
assert_eq!(a.text_len, b.text_len, "{case} text_len mismatch");
assert_eq!(a.width, b.width, "{case} width mismatch");
assert_eq!(a.full_width, b.full_width, "{case} full_width mismatch");
assert_eq!(a.height, b.height, "{case} height mismatch");
assert_eq!(a.fonts, b.fonts, "{case} fonts mismatch");
assert_eq!(a.coords, b.coords, "{case} coords mismatch");
// Input (/ output of style resolution)
assert_eq!(a.styles, b.styles, "{case} styles mismatch");
assert_eq!(
a.inline_boxes, b.inline_boxes,
"{case} inline_boxes mismatch"
);
// Output of shaping
assert_eq!(a.runs, b.runs, "{case} runs mismatch");
assert_eq!(a.items, b.items, "{case} items mismatch");
assert_eq!(a.clusters, b.clusters, "{case} clusters mismatch");
assert_eq!(a.glyphs, b.glyphs, "{case} glyphs mismatch");
// Output of line breaking
assert_eq!(a.lines, b.lines, "{case} lines mismatch");
assert_eq!(a.line_items, b.line_items, "{case} line_items mismatch");
// Output of alignment
assert_eq!(
a.is_aligned_justified, b.is_aligned_justified,
"{case} is_aligned_justified mismatch"
);
assert_eq!(
a.layout_max_advance, b.layout_max_advance,
"{case} alignment_width mismatch"
);
// Also compare the whole struct in case any fields have been added that aren't
// part of this test yet. If this triggers, add the missing assert to the above set.
assert_eq!(a, b, "{case} LayoutData mismatch");
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2024 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
use peniko::Color;
pub(crate) mod asserts;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ColorBrush {
pub(crate) color: Color,
}
impl ColorBrush {
pub(crate) fn new(color: Color) -> Self {
let rgba8 = color.to_rgba8();
Self {
color: Color::from_rgba8(rgba8.r, rgba8.g, rgba8.b, rgba8.a),
}
}
}
impl Default for ColorBrush {
fn default() -> Self {
Self {
color: Color::BLACK,
}
}
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2021 the Parley Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Misc helpers.
pub(crate) fn nearly_eq(x: f32, y: f32) -> bool {
(x - y).abs() < f32::EPSILON
}
pub(crate) fn nearly_zero(x: f32) -> bool {
nearly_eq(x, 0.)
}
/// A bounding box.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct BoundingBox {
/// The left edge.
pub x0: f64,
/// The top edge.
pub y0: f64,
/// The right edge.
pub x1: f64,
/// The bottom edge.
pub y1: f64,
}
impl BoundingBox {
/// A new bounding box from minimum and maximum coordinates.
#[inline(always)]
pub const fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self {
Self { x0, y0, x1, y1 }
}
/// The width of the bounding box.
///
/// Note: nothing forbids negative width.
#[inline]
pub fn width(&self) -> f64 {
self.x1 - self.x0
}
/// The height of the bounding box.
///
/// Note: nothing forbids negative height.
#[inline]
pub fn height(&self) -> f64 {
self.y1 - self.y0
}
/// The smallest bounding box enclosing two bounding boxes.
///
/// Results are valid only if width and height are non-negative.
#[inline]
pub fn union(&self, other: Self) -> Self {
Self::new(
self.x0.min(other.x0),
self.y0.min(other.y0),
self.x1.max(other.x1),
self.y1.max(other.y1),
)
}
}