use std::{
cmp::Ordering,
collections::{
hash_map::{DefaultHasher, HashMap},
BTreeSet, HashSet,
},
hash::{Hash, Hasher},
mem::discriminant,
num::NonZeroUsize,
sync::{Arc, Mutex},
};
pub use azul_core::selection::{ContentIndex, GraphemeClusterId};
use azul_core::{
dom::NodeId,
geom::{LogicalPosition, LogicalRect, LogicalSize},
resources::ImageRef,
selection::{CursorAffinity, SelectionRange, TextCursor},
ui_solver::GlyphInstance,
};
use azul_css::{
corety::LayoutDebugMessage, props::basic::ColorU, props::style::StyleBackgroundContent,
};
#[cfg(feature = "text_layout_hyphenation")]
use hyphenation::{Hyphenator, Language as HyphenationLanguage, Load, Standard};
use rust_fontconfig::{FcFontCache, FcPattern, FcStretch, FcWeight, FontId, PatternMatch, UnicodeRange};
use smallvec::{smallvec, SmallVec};
use unicode_bidi::{BidiInfo, Level, TextSource};
use unicode_segmentation::UnicodeSegmentation;
const FALLBACK_ASCENT_RATIO: f32 = 0.8;
const FALLBACK_DESCENT_RATIO: f32 = 1.0 - FALLBACK_ASCENT_RATIO;
const DEFAULT_STRUT_ASCENT: f32 = 12.8;
const DEFAULT_STRUT_DESCENT: f32 = 3.2;
const DEFAULT_X_HEIGHT: f32 = 8.0;
const DEFAULT_CH_WIDTH: f32 = 8.0;
const SPACE_WIDTH_RATIO: f32 = 0.5;
const SUBSCRIPT_OFFSET_RATIO: f32 = 0.3;
const SUPERSCRIPT_OFFSET_RATIO: f32 = 0.4;
const RUBY_ANNOTATION_FONT_SCALE: f32 = 0.5;
fn ruby_reserved_box(
base_width: f32,
annotation_width: f32,
base_line_height: f32,
annotation_line_height: f32,
) -> (f32, f32) {
(
base_width.max(annotation_width),
base_line_height + annotation_line_height,
)
}
pub type ShapedGlyphVec = SmallVec<[ShapedGlyph; 1]>;
#[derive(Debug, Clone, Copy)]
#[derive(Default)]
pub enum LineHeight {
#[default]
Normal,
Px(f32),
}
impl LineHeight {
#[must_use] pub fn resolve(&self, font_size_px: f32, ascent: f32, descent: f32, line_gap: f32, units_per_em: u16) -> f32 {
match self {
Self::Px(px) => *px,
Self::Normal => {
if units_per_em == 0 {
return font_size_px * 1.2; }
let scale = font_size_px / f32::from(units_per_em);
(ascent - descent + line_gap) * scale
}
}
}
#[must_use] pub fn resolve_with_metrics(&self, font_size_px: f32, metrics: &LayoutFontMetrics) -> f32 {
self.resolve(font_size_px, metrics.ascent, metrics.descent, metrics.line_gap, metrics.units_per_em)
}
}
impl PartialEq for LineHeight {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Normal, Self::Normal) => true,
(Self::Px(a), Self::Px(b)) => a.to_bits() == b.to_bits(),
_ => false,
}
}
}
impl Eq for LineHeight {}
impl Hash for LineHeight {
fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
if let Self::Px(v) = self {
v.to_bits().hash(state);
}
}
}
#[cfg(not(feature = "text_layout_hyphenation"))]
pub struct Standard;
#[cfg(not(feature = "text_layout_hyphenation"))]
impl Standard {
pub fn hyphenate<'a>(&'a self, _word: &'a str) -> StubHyphenationBreaks {
StubHyphenationBreaks { breaks: Vec::new() }
}
}
#[cfg(not(feature = "text_layout_hyphenation"))]
pub struct StubHyphenationBreaks {
pub breaks: Vec<usize>,
}
use crate::text3::script::{script_to_language, Language, Script};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AvailableSpace {
Definite(f32),
MinContent,
MaxContent,
}
impl Default for AvailableSpace {
fn default() -> Self {
Self::MaxContent
}
}
impl AvailableSpace {
#[must_use] pub const fn is_definite(&self) -> bool {
matches!(self, Self::Definite(_))
}
#[must_use] pub const fn is_indefinite(&self) -> bool {
!self.is_definite()
}
#[must_use] pub const fn unwrap_or(self, fallback: f32) -> f32 {
match self {
Self::Definite(v) => v,
_ => fallback,
}
}
#[allow(clippy::match_same_arms)] #[must_use] pub fn to_f32_for_layout(self) -> f32 {
match self {
Self::Definite(v) => v,
Self::MinContent => f32::MAX / 2.0,
Self::MaxContent => f32::MAX / 2.0,
}
}
#[must_use] pub fn from_f32(value: f32) -> Self {
if value.is_infinite() || value >= f32::MAX / 2.0 {
Self::MaxContent
} else if value <= 0.0 {
Self::MinContent
} else {
Self::Definite(value)
}
}
}
impl Hash for AvailableSpace {
fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
if let Self::Definite(v) = self {
let normalized = if *v == 0.0 { 0.0f32 } else { *v };
normalized.to_bits().hash(state);
}
}
}
pub use crate::font_traits::{ParsedFontTrait, ShallowClone};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FontChainKey {
pub font_families: Vec<String>,
pub weight: FcWeight,
pub italic: bool,
pub oblique: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FontChainKeyOrRef {
Chain(FontChainKey),
Ref(usize),
}
impl FontChainKeyOrRef {
#[must_use] pub fn from_font_stack(font_stack: &FontStack) -> Self {
match font_stack {
FontStack::Stack(selectors) => Self::Chain(FontChainKey::from_selectors(selectors)),
FontStack::Ref(font_ref) => Self::Ref(font_ref.parsed as usize),
}
}
#[must_use] pub const fn is_ref(&self) -> bool {
matches!(self, Self::Ref(_))
}
#[must_use] pub const fn as_ref_ptr(&self) -> Option<usize> {
match self {
Self::Ref(ptr) => Some(*ptr),
Self::Chain(_) => None,
}
}
#[must_use] pub const fn as_chain(&self) -> Option<&FontChainKey> {
match self {
Self::Chain(key) => Some(key),
Self::Ref(_) => None,
}
}
}
impl FontChainKey {
#[must_use] pub fn from_selectors(font_stack: &[FontSelector]) -> Self {
let mut font_families: Vec<String> = Vec::new();
for sel in font_stack {
if sel.family.is_empty() || font_families.contains(&sel.family) {
continue;
}
font_families.push(sel.family.clone());
}
let font_families = if font_families.is_empty() {
vec!["serif".to_string()]
} else {
font_families
};
let weight = font_stack
.first()
.map_or(FcWeight::Normal, |s| s.weight);
let is_italic = font_stack
.first()
.is_some_and(|s| s.style == FontStyle::Italic);
let is_oblique = font_stack
.first()
.is_some_and(|s| s.style == FontStyle::Oblique);
Self {
font_families,
weight,
italic: is_italic,
oblique: is_oblique,
}
}
}
#[derive(Debug, Clone)]
pub struct LoadedFonts<T> {
pub fonts: HashMap<FontId, T>,
hash_to_id: HashMap<u64, FontId>,
}
impl<T: ParsedFontTrait> LoadedFonts<T> {
#[must_use] pub fn new() -> Self {
Self {
fonts: HashMap::new(),
hash_to_id: HashMap::new(),
}
}
pub fn insert(&mut self, font_id: FontId, font: T) {
let hash = font.get_hash();
self.hash_to_id.insert(hash, font_id);
self.fonts.insert(font_id, font);
}
#[must_use] pub fn get(&self, font_id: &FontId) -> Option<&T> {
self.fonts.get(font_id)
}
#[must_use] pub fn get_by_hash(&self, hash: u64) -> Option<&T> {
self.hash_to_id.get(&hash).and_then(|id| self.fonts.get(id))
}
#[must_use] pub fn get_font_id_by_hash(&self, hash: u64) -> Option<&FontId> {
self.hash_to_id.get(&hash)
}
#[must_use] pub fn contains_key(&self, font_id: &FontId) -> bool {
self.fonts.contains_key(font_id)
}
#[must_use] pub fn contains_hash(&self, hash: u64) -> bool {
self.hash_to_id.contains_key(&hash)
}
pub fn iter(&self) -> impl Iterator<Item = (&FontId, &T)> {
self.fonts.iter()
}
#[must_use] pub fn len(&self) -> usize {
self.fonts.len()
}
#[must_use] pub fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
}
impl<T: ParsedFontTrait> Default for LoadedFonts<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: ParsedFontTrait> FromIterator<(FontId, T)> for LoadedFonts<T> {
fn from_iter<I: IntoIterator<Item = (FontId, T)>>(iter: I) -> Self {
let mut loaded = Self::new();
for (id, font) in iter {
loaded.insert(id, font);
}
loaded
}
}
#[derive(Debug, Clone)]
pub enum FontOrRef<T> {
Font(T),
Ref(azul_css::props::basic::FontRef),
}
impl<T: ParsedFontTrait> ShallowClone for FontOrRef<T> {
fn shallow_clone(&self) -> Self {
match self {
Self::Font(f) => Self::Font(f.shallow_clone()),
Self::Ref(r) => Self::Ref(r.clone()),
}
}
}
impl<T: ParsedFontTrait> ParsedFontTrait for FontOrRef<T> {
fn shape_text(
&self,
text: &str,
script: Script,
language: Language,
direction: BidiDirection,
style: &StyleProperties,
) -> Result<Vec<Glyph>, LayoutError> {
match self {
Self::Font(f) => f.shape_text(text, script, language, direction, style),
Self::Ref(r) => r.shape_text(text, script, language, direction, style),
}
}
fn get_hash(&self) -> u64 {
match self {
Self::Font(f) => f.get_hash(),
Self::Ref(r) => r.get_hash(),
}
}
fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
match self {
Self::Font(f) => f.get_glyph_size(glyph_id, font_size),
Self::Ref(r) => r.get_glyph_size(glyph_id, font_size),
}
}
fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
match self {
Self::Font(f) => f.get_hyphen_glyph_and_advance(font_size),
Self::Ref(r) => r.get_hyphen_glyph_and_advance(font_size),
}
}
fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
match self {
Self::Font(f) => f.get_kashida_glyph_and_advance(font_size),
Self::Ref(r) => r.get_kashida_glyph_and_advance(font_size),
}
}
fn has_glyph(&self, codepoint: u32) -> bool {
match self {
Self::Font(f) => f.has_glyph(codepoint),
Self::Ref(r) => r.has_glyph(codepoint),
}
}
fn get_vertical_metrics(&self, glyph_id: u16) -> Option<VerticalMetrics> {
match self {
Self::Font(f) => f.get_vertical_metrics(glyph_id),
Self::Ref(r) => r.get_vertical_metrics(glyph_id),
}
}
fn get_font_metrics(&self) -> LayoutFontMetrics {
match self {
Self::Font(f) => f.get_font_metrics(),
Self::Ref(r) => r.get_font_metrics(),
}
}
fn num_glyphs(&self) -> u16 {
match self {
Self::Font(f) => f.num_glyphs(),
Self::Ref(r) => r.num_glyphs(),
}
}
fn get_space_width(&self) -> Option<usize> {
match self {
Self::Font(f) => f.get_space_width(),
Self::Ref(r) => r.get_space_width(),
}
}
}
#[derive(Debug, Clone)]
pub struct FontContext {
pub fc_cache: FcFontCache,
pub parsed_fonts: Arc<Mutex<HashMap<FontId, azul_css::props::basic::FontRef>>>,
pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
pub embedded_fonts: HashMap<u64, azul_css::props::basic::FontRef>,
pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
}
impl FontContext {
#[must_use] pub fn from_fc_cache(fc_cache: FcFontCache) -> Self {
Self {
fc_cache,
parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
font_chain_cache: HashMap::new(),
embedded_fonts: HashMap::new(),
font_hash_to_families: HashMap::new(),
registry: None,
}
}
pub fn from_registry(
registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
) -> Self {
let fc_cache = registry.shared_cache();
Self {
fc_cache,
parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
font_chain_cache: HashMap::new(),
embedded_fonts: HashMap::new(),
font_hash_to_families: HashMap::new(),
registry: Some(registry),
}
}
pub fn pre_resolve_chains_for_dom(
&mut self,
styled_dom: &azul_core::styled_dom::StyledDom,
platform: &azul_css::system::Platform,
) {
use crate::solver3::getters::{
collect_font_stacks_from_styled_dom, collect_used_codepoints,
prune_chain_to_used_chars, resolve_font_chains, scripts_present_in_styled_dom,
};
let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
let scripts = scripts_present_in_styled_dom(styled_dom);
let mut chains = resolve_font_chains(&collected, &self.fc_cache, Some(&scripts));
let used_chars = collect_used_codepoints(styled_dom);
for chain in chains.chains.values_mut() {
prune_chain_to_used_chars(chain, &used_chars);
}
for chain in chains.chains.values_mut() {
let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
+ chain.unicode_fallbacks.len();
if total == 0 {
if let Some((pattern, id)) = self.fc_cache.list().first() {
chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
id: *id,
unicode_ranges: pattern.unicode_ranges.clone(),
fallbacks: Vec::new(),
});
}
}
}
self.font_chain_cache = chains.into_fontconfig_chains();
}
pub fn load_fonts_for_chains(&self) {
use crate::solver3::getters::ResolvedFontChains;
use crate::text3::default::PathLoader;
let chains_map: HashMap<FontChainKeyOrRef, _> = self
.font_chain_cache
.iter()
.map(|(k, v)| (FontChainKeyOrRef::Chain(k.clone()), v.clone()))
.collect();
let resolved = ResolvedFontChains {
chains: chains_map,
..Default::default()
};
let Ok(manager) = FontManager::<azul_css::props::basic::FontRef>::from_arc_shared(
self.fc_cache.clone(),
self.parsed_fonts.clone(),
) else {
return;
};
let loader = PathLoader::new();
let _failed = manager
.load_missing_for_chains(&resolved, |bytes, idx| loader.load_font_shared(bytes, idx));
}
#[must_use] pub fn to_font_manager(&self) -> FontManager<azul_css::props::basic::FontRef> {
let mut fm = FontManager {
fc_cache: self.fc_cache.clone(),
parsed_fonts: self.parsed_fonts.clone(),
font_chain_cache: self.font_chain_cache.clone(),
embedded_fonts: Mutex::new(self.embedded_fonts.clone()),
font_hash_to_families: self.font_hash_to_families.clone(),
registry: self.registry.clone(),
last_resolved_font_stacks_sig: None,
memory_families: HashMap::new(),
vf_bake_cache: HashMap::new(),
};
fm.register_builtin_mock_fonts();
fm
}
}
#[derive(Debug, Clone)]
pub struct MemoryFace {
pub font_match: rust_fontconfig::FontMatch,
pub weight: FcWeight,
pub italic: bool,
pub oblique: bool,
pub stretch: FcStretch,
pub weight_axis: Option<(f32, f32)>,
}
#[derive(Debug, Clone, Copy)]
struct FaceStyle {
weight: FcWeight,
italic: bool,
oblique: bool,
stretch: FcStretch,
weight_axis: Option<(f32, f32)>,
}
impl Default for FaceStyle {
fn default() -> Self {
Self {
weight: FcWeight::Normal,
italic: false,
oblique: false,
stretch: FcStretch::Normal,
weight_axis: None,
}
}
}
fn parse_face_style(bytes: &[u8], family: &str) -> FaceStyle {
let Some(faces) = rust_fontconfig::FcParseFontBytes(bytes, family) else {
return FaceStyle::default();
};
let Some((pat, _)) = faces.into_iter().next() else {
return FaceStyle::default();
};
FaceStyle {
weight: pat.weight,
italic: pat.italic == PatternMatch::True,
oblique: pat.oblique == PatternMatch::True,
stretch: pat.stretch,
weight_axis: None,
}
}
#[derive(Debug)]
pub struct FontManager<T> {
pub fc_cache: FcFontCache,
pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
pub embedded_fonts: Mutex<HashMap<u64, azul_css::props::basic::FontRef>>,
pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
pub last_resolved_font_stacks_sig: Option<u64>,
pub memory_families: HashMap<String, Vec<MemoryFace>>,
vf_bake_cache: HashMap<u64, Vec<(FontId, FaceStyle)>>,
}
impl<T: ParsedFontTrait> FontManager<T> {
pub fn new(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
let mut fm = Self {
fc_cache,
parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
font_chain_cache: HashMap::new(),
embedded_fonts: Mutex::new(HashMap::new()),
font_hash_to_families: HashMap::new(),
registry: None,
last_resolved_font_stacks_sig: None,
memory_families: HashMap::new(),
vf_bake_cache: HashMap::new(),
};
fm.register_builtin_mock_fonts();
Ok(fm)
}
pub fn register_named_font(
&mut self,
family: &str,
bytes: &[u8],
coverage: Vec<UnicodeRange>,
) -> FontId {
let norm = rust_fontconfig::utils::normalize_family_name(family);
if let Some((min, def, max)) = crate::font::parsed::read_wght_axis(bytes, 0) {
if let Some(id) =
self.register_variable_instances(&norm, family, bytes, &coverage, min, def, max)
{
return id;
}
}
let style = parse_face_style(bytes, family);
let mut existing: Vec<(FontId, Vec<UnicodeRange>)> = Vec::new();
self.fc_cache.for_each_pattern(|pattern, id| {
let fam_hit = pattern
.family
.as_deref()
.is_some_and(|f| rust_fontconfig::utils::normalize_family_name(f) == norm);
let style_hit = pattern.weight == style.weight
&& (pattern.italic == PatternMatch::True) == style.italic
&& (pattern.oblique == PatternMatch::True) == style.oblique;
if fam_hit && style_hit {
existing.push((*id, pattern.unicode_ranges.clone()));
}
});
let id = if let Some((id, ranges)) = existing
.into_iter()
.find(|(id, _)| self.fc_cache.is_memory_font(id))
{
self.index_memory_face(&norm, id, ranges, &style);
id
} else {
let pattern = rust_fontconfig::FcPattern {
name: Some(family.to_string()),
family: Some(family.to_string()),
italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
weight: style.weight,
stretch: style.stretch,
unicode_ranges: coverage.clone(),
..Default::default()
};
let id = FontId::new();
self.fc_cache.with_memory_font_with_id(
id,
pattern,
rust_fontconfig::FcFont {
bytes: bytes.to_vec(),
font_index: 0,
id: family.to_string(),
},
);
self.index_memory_face(&norm, id, coverage, &style);
id
};
id
}
fn index_memory_face(
&mut self,
norm: &str,
id: FontId,
unicode_ranges: Vec<UnicodeRange>,
style: &FaceStyle,
) {
let face = MemoryFace {
font_match: rust_fontconfig::FontMatch {
id,
unicode_ranges,
fallbacks: Vec::new(),
},
weight: style.weight,
italic: style.italic,
oblique: style.oblique,
stretch: style.stretch,
weight_axis: style.weight_axis,
};
let faces = self.memory_families.entry(norm.to_string()).or_default();
if let Some(slot) = faces.iter_mut().find(|f| f.font_match.id == id) {
*slot = face;
} else {
faces.push(face);
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn register_variable_instances(
&mut self,
norm: &str,
family: &str,
bytes: &[u8],
coverage: &[UnicodeRange],
min: f32,
def: f32,
max: f32,
) -> Option<FontId> {
let base = parse_face_style(bytes, family);
let hash = {
use core::hash::Hasher;
let mut h = DefaultHasher::new();
h.write(bytes);
h.finish()
};
let def_bucket = FcWeight::from_u16(def.round().clamp(1.0, 1000.0) as u16);
if let Some(cached) = self.vf_bake_cache.get(&hash).cloned() {
for (id, style) in &cached {
self.index_memory_face(norm, *id, coverage.to_vec(), style);
}
return cached
.iter()
.find(|(_, s)| s.weight == def_bucket)
.or_else(|| cached.first())
.map(|(id, _)| *id);
}
let lo = min.round().clamp(1.0, 1000.0) as u16;
let hi = max.round().clamp(1.0, 1000.0) as u16;
let mut baked: Vec<(FontId, FaceStyle)> = Vec::new();
for w in [100u16, 200, 300, 400, 500, 600, 700, 800, 900] {
if w < lo || w > hi {
continue;
}
let Some(inst_bytes) = crate::font::parsed::bake_weight_instance(bytes, 0, f32::from(w))
else {
continue;
};
let style = FaceStyle {
weight: FcWeight::from_u16(w),
italic: base.italic,
oblique: base.oblique,
stretch: base.stretch,
weight_axis: None,
};
let pattern = rust_fontconfig::FcPattern {
name: Some(family.to_string()),
family: Some(family.to_string()),
italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
weight: style.weight,
stretch: style.stretch,
unicode_ranges: coverage.to_vec(),
..Default::default()
};
let id = FontId::new();
self.fc_cache.with_memory_font_with_id(
id,
pattern,
rust_fontconfig::FcFont {
bytes: inst_bytes,
font_index: 0,
id: family.to_string(),
},
);
self.index_memory_face(norm, id, coverage.to_vec(), &style);
baked.push((id, style));
}
if baked.is_empty() {
return None;
}
let default_id = baked
.iter()
.find(|(_, s)| s.weight == def_bucket)
.or_else(|| baked.first())
.map(|(id, _)| *id)
.unwrap();
self.vf_bake_cache.insert(hash, baked);
Some(default_id)
}
pub fn register_builtin_mock_fonts(&mut self) {
for (family, bytes) in crate::text3::mock_fonts::BUILTIN_MOCK_FONTS {
self.register_named_font(
family,
bytes,
crate::text3::mock_fonts::mock_font_ranges(),
);
}
}
pub fn from_shared(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
Self::new(fc_cache)
}
pub fn from_arc_shared(
fc_cache: FcFontCache,
parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
) -> Result<Self, LayoutError> {
let mut fm = Self {
fc_cache,
parsed_fonts,
font_chain_cache: HashMap::new(),
embedded_fonts: Mutex::new(HashMap::new()),
font_hash_to_families: HashMap::new(),
registry: None,
last_resolved_font_stacks_sig: None,
memory_families: HashMap::new(),
vf_bake_cache: HashMap::new(),
};
fm.register_builtin_mock_fonts();
Ok(fm)
}
#[must_use]
pub fn with_registry(
mut self,
registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
) -> Self {
self.registry = Some(registry);
self
}
pub fn shared_parsed_fonts(&self) -> Arc<Mutex<HashMap<FontId, T>>> {
Arc::clone(&self.parsed_fonts)
}
pub fn set_font_chain_cache(
&mut self,
chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
) {
self.font_chain_cache = chains;
self.last_resolved_font_stacks_sig = None;
}
pub fn set_font_chain_cache_with_sig(
&mut self,
chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
sig: Option<u64>,
) {
self.font_chain_cache = chains;
self.last_resolved_font_stacks_sig = sig;
}
pub fn merge_font_chain_cache(
&mut self,
chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
) {
self.font_chain_cache.extend(chains);
}
pub const fn get_font_chain_cache(
&self,
) -> &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain> {
&self.font_chain_cache
}
pub fn get_embedded_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef> {
let embedded = self.embedded_fonts.lock().unwrap();
embedded.get(&font_hash).cloned()
}
pub fn get_font_by_hash(&self, font_hash: u64) -> Option<T> {
let parsed = self.parsed_fonts.lock().unwrap();
let found = parsed
.iter()
.find(|(_, font)| font.get_hash() == font_hash)
.map(|(_, font)| font.clone());
drop(parsed);
found
}
pub fn register_embedded_font(&self, font_ref: &azul_css::props::basic::FontRef) {
let hash = font_ref.get_hash();
let mut embedded = self.embedded_fonts.lock().unwrap();
embedded.insert(hash, font_ref.clone());
}
pub fn get_loaded_fonts(&self) -> LoadedFonts<T> {
let parsed = self.parsed_fonts.lock().unwrap();
parsed
.iter()
.map(|(id, font)| (*id, font.shallow_clone()))
.collect()
}
pub fn get_loaded_font_ids(&self) -> HashSet<FontId> {
let parsed = self.parsed_fonts.lock().unwrap();
if parsed.is_empty() {
return HashSet::new();
}
unsafe { crate::az_mark(0x60788, 0xA1) };
let out = parsed.keys().copied().collect();
drop(parsed);
unsafe { crate::az_mark(0x6078C, 0xA2) };
out
}
pub fn insert_font(&self, font_id: FontId, font: T) -> Option<T> {
let mut parsed = self.parsed_fonts.lock().unwrap();
parsed.insert(font_id, font)
}
pub fn insert_fonts(&self, fonts: impl IntoIterator<Item = (FontId, T)>) {
let mut parsed = self.parsed_fonts.lock().unwrap();
for (font_id, font) in fonts {
parsed.insert(font_id, font);
}
}
pub fn load_missing_for_chains<F>(
&self,
chains: &crate::solver3::getters::ResolvedFontChains,
load_fn: F,
) -> Vec<(FontId, String)>
where
F: Fn(Arc<rust_fontconfig::FontBytes>, usize) -> Result<T, LayoutError>,
{
use crate::solver3::getters::{
collect_font_ids_from_chains, compute_fonts_to_load, load_fonts_from_disk,
};
let required = collect_font_ids_from_chains(chains);
let already = self.get_loaded_font_ids();
let to_load = compute_fonts_to_load(&required, &already);
if to_load.is_empty() {
return Vec::new();
}
let result = load_fonts_from_disk(&to_load, &self.fc_cache, load_fn);
self.insert_fonts(result.loaded);
result.failed
}
pub fn replace_fc_cache(&mut self, fc_cache: FcFontCache) {
self.fc_cache = fc_cache;
self.register_builtin_mock_fonts();
}
pub fn remove_font(&self, font_id: &FontId) -> Option<T> {
let mut parsed = self.parsed_fonts.lock().unwrap();
parsed.remove(font_id)
}
pub fn garbage_collect_fonts(
&mut self,
keep_ids: &HashSet<FontId>,
keep_hashes: &HashSet<u64>,
) -> usize {
let evicted = {
let mut parsed = self.parsed_fonts.lock().unwrap();
let before = parsed.len();
parsed.retain(|id, _| keep_ids.contains(id));
before.saturating_sub(parsed.len())
};
self.font_hash_to_families
.retain(|h, _| keep_hashes.contains(h));
evicted
}
}
#[derive(Debug, thiserror::Error)]
#[repr(C, u8)]
pub enum LayoutError {
#[error("Bidi analysis failed: {0}")]
BidiError(String),
#[error("Shaping failed: {0}")]
ShapingError(String),
#[error("Font not found: {0:?}")]
FontNotFound(FontSelector),
#[error("Invalid text input: {0}")]
InvalidText(String),
#[error("Hyphenation failed: {0}")]
HyphenationError(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextBoundary {
Top,
Bottom,
Start,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CursorBoundsError {
pub(crate) boundary: TextBoundary,
pub(crate) cursor: TextCursor,
}
#[derive(Debug, Clone)]
pub struct UnifiedConstraints {
pub shape_boundaries: Vec<ShapeBoundary>,
pub shape_exclusions: Vec<ShapeBoundary>,
pub available_width: AvailableSpace,
pub available_height: Option<f32>,
pub writing_mode: Option<WritingMode>,
pub direction: Option<BidiDirection>,
pub text_orientation: TextOrientation,
pub text_align: TextAlign,
pub text_justify: JustifyContent,
pub line_height: LineHeight,
pub vertical_align: VerticalAlign,
pub strut_ascent: f32,
pub strut_descent: f32,
pub strut_x_height: f32,
pub ch_width: f32,
pub overflow: OverflowBehavior,
pub segment_alignment: SegmentAlignment,
pub text_combine_upright: Option<TextCombineUpright>,
pub exclusion_margin: f32,
pub hyphenation: Hyphens,
pub hyphenation_language: Option<Language>,
pub text_indent: f32,
pub text_indent_each_line: bool,
pub text_indent_hanging: bool,
pub initial_letter: Option<InitialLetter>,
pub line_clamp: Option<NonZeroUsize>,
pub text_wrap: TextWrap,
pub columns: u32,
pub column_gap: f32,
pub hanging_punctuation: bool,
pub overflow_wrap: OverflowWrap,
pub text_align_last: TextAlign,
pub word_break: WordBreak,
pub white_space_mode: WhiteSpaceMode,
pub line_break: LineBreakStrictness,
pub unicode_bidi: UnicodeBidi,
}
impl Default for UnifiedConstraints {
fn default() -> Self {
Self {
shape_boundaries: Vec::new(),
shape_exclusions: Vec::new(),
available_width: AvailableSpace::MaxContent,
available_height: None,
writing_mode: None,
direction: None, text_orientation: TextOrientation::default(),
text_align: TextAlign::default(),
text_justify: JustifyContent::default(),
line_height: LineHeight::Normal,
vertical_align: VerticalAlign::default(),
strut_ascent: DEFAULT_STRUT_ASCENT,
strut_descent: DEFAULT_STRUT_DESCENT,
strut_x_height: DEFAULT_X_HEIGHT,
ch_width: DEFAULT_CH_WIDTH,
overflow: OverflowBehavior::default(),
segment_alignment: SegmentAlignment::default(),
text_combine_upright: None,
exclusion_margin: 0.0,
hyphenation: Hyphens::default(),
hyphenation_language: None,
columns: 1,
column_gap: 0.0,
hanging_punctuation: false,
text_indent: 0.0,
text_indent_each_line: false,
text_indent_hanging: false,
initial_letter: None,
line_clamp: None,
text_wrap: TextWrap::default(),
overflow_wrap: OverflowWrap::default(),
text_align_last: TextAlign::default(),
word_break: WordBreak::default(),
white_space_mode: WhiteSpaceMode::default(),
line_break: LineBreakStrictness::default(),
unicode_bidi: UnicodeBidi::default(),
}
}
}
impl Hash for UnifiedConstraints {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
self.shape_boundaries.hash(state);
self.shape_exclusions.hash(state);
self.available_width.hash(state);
self.available_height
.map(|h| h.round() as isize)
.hash(state);
self.writing_mode.hash(state);
self.direction.hash(state);
self.text_orientation.hash(state);
self.text_align.hash(state);
self.text_justify.hash(state);
self.line_height.hash(state);
self.vertical_align.hash(state);
(self.strut_ascent.round() as isize).hash(state);
(self.strut_descent.round() as isize).hash(state);
(self.strut_x_height.round() as isize).hash(state);
(self.ch_width.round() as isize).hash(state);
self.overflow.hash(state);
self.segment_alignment.hash(state);
self.text_combine_upright.hash(state);
(self.exclusion_margin.round() as isize).hash(state);
self.hyphenation.hash(state);
self.hyphenation_language.hash(state);
(self.text_indent.round() as isize).hash(state);
self.text_indent_each_line.hash(state);
self.text_indent_hanging.hash(state);
self.initial_letter.hash(state);
self.line_clamp.hash(state);
self.columns.hash(state);
(self.column_gap.round() as isize).hash(state);
self.hanging_punctuation.hash(state);
self.overflow_wrap.hash(state);
self.text_align_last.hash(state);
self.word_break.hash(state);
self.white_space_mode.hash(state);
self.line_break.hash(state);
self.unicode_bidi.hash(state);
}
}
impl PartialEq for UnifiedConstraints {
fn eq(&self, other: &Self) -> bool {
self.shape_boundaries == other.shape_boundaries
&& self.shape_exclusions == other.shape_exclusions
&& self.available_width == other.available_width
&& match (self.available_height, other.available_height) {
(None, None) => true,
(Some(h1), Some(h2)) => round_eq(h1, h2),
_ => false,
}
&& self.writing_mode == other.writing_mode
&& self.direction == other.direction
&& self.text_orientation == other.text_orientation
&& self.text_align == other.text_align
&& self.text_justify == other.text_justify
&& self.line_height == other.line_height
&& self.vertical_align == other.vertical_align
&& round_eq(self.strut_ascent, other.strut_ascent)
&& round_eq(self.strut_descent, other.strut_descent)
&& round_eq(self.strut_x_height, other.strut_x_height)
&& round_eq(self.ch_width, other.ch_width)
&& self.overflow == other.overflow
&& self.segment_alignment == other.segment_alignment
&& self.text_combine_upright == other.text_combine_upright
&& round_eq(self.exclusion_margin, other.exclusion_margin)
&& self.hyphenation == other.hyphenation
&& self.hyphenation_language == other.hyphenation_language
&& round_eq(self.text_indent, other.text_indent)
&& self.text_indent_each_line == other.text_indent_each_line
&& self.text_indent_hanging == other.text_indent_hanging
&& self.initial_letter == other.initial_letter
&& self.line_clamp == other.line_clamp
&& self.columns == other.columns
&& round_eq(self.column_gap, other.column_gap)
&& self.hanging_punctuation == other.hanging_punctuation
&& self.overflow_wrap == other.overflow_wrap
&& self.text_align_last == other.text_align_last
&& self.word_break == other.word_break
&& self.white_space_mode == other.white_space_mode
&& self.line_break == other.line_break
&& self.unicode_bidi == other.unicode_bidi
}
}
impl Eq for UnifiedConstraints {}
impl UnifiedConstraints {
#[must_use] pub fn resolved_line_height(&self) -> f32 {
match self.line_height {
LineHeight::Normal => self.strut_ascent + self.strut_descent,
LineHeight::Px(px) => px,
}
}
fn direction(&self, fallback: BidiDirection) -> BidiDirection {
self.writing_mode.map_or(fallback, |s| s.get_direction().unwrap_or(fallback))
}
const fn is_vertical(&self) -> bool {
matches!(
self.writing_mode,
Some(WritingMode::VerticalRl | WritingMode::VerticalLr)
)
}
}
#[derive(Debug, Clone)]
pub struct LineConstraints {
pub segments: Vec<LineSegment>,
pub total_available: f32,
pub is_min_content: bool,
}
impl WritingMode {
#[allow(clippy::trivially_copy_pass_by_ref)] #[allow(clippy::match_same_arms)] const fn get_direction(&self) -> Option<BidiDirection> {
match self {
Self::HorizontalTb => None,
Self::VerticalRl => Some(BidiDirection::Rtl),
Self::VerticalLr => Some(BidiDirection::Ltr),
Self::SidewaysRl => Some(BidiDirection::Rtl),
Self::SidewaysLr => Some(BidiDirection::Ltr),
}
}
}
#[derive(Debug, Clone, Hash)]
pub struct StyledRun {
pub text: String,
pub style: Arc<StyleProperties>,
pub logical_start_byte: usize,
pub source_node_id: Option<NodeId>,
}
#[derive(Debug, Clone)]
pub struct VisualRun<'a> {
pub text_slice: &'a str,
pub style: Arc<StyleProperties>,
pub logical_start_byte: usize,
pub bidi_level: BidiLevel,
pub script: Script,
pub language: Language,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FontSelector {
pub family: String,
pub weight: FcWeight,
pub style: FontStyle,
pub unicode_ranges: Vec<UnicodeRange>,
}
impl Default for FontSelector {
fn default() -> Self {
Self {
family: "serif".to_string(),
weight: FcWeight::Normal,
style: FontStyle::Normal,
unicode_ranges: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
#[repr(C, u8)]
pub enum FontStack {
Stack(Vec<FontSelector>),
Ref(azul_css::props::basic::font::FontRef),
}
impl Default for FontStack {
fn default() -> Self {
Self::Stack(vec![FontSelector::default()])
}
}
impl FontStack {
#[must_use] pub const fn is_ref(&self) -> bool {
matches!(self, Self::Ref(_))
}
#[must_use] pub const fn as_ref(&self) -> Option<&azul_css::props::basic::font::FontRef> {
match self {
Self::Ref(r) => Some(r),
Self::Stack(_) => None,
}
}
#[must_use] pub fn as_stack(&self) -> Option<&[FontSelector]> {
match self {
Self::Stack(s) => Some(s),
Self::Ref(_) => None,
}
}
#[must_use] pub fn first_selector(&self) -> Option<&FontSelector> {
match self {
Self::Stack(s) => s.first(),
Self::Ref(_) => None,
}
}
#[must_use] pub fn first_family(&self) -> &str {
match self {
Self::Stack(s) => s.first().map_or("serif", |f| f.family.as_str()),
Self::Ref(_) => "<embedded-font>",
}
}
}
impl PartialEq for FontStack {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Stack(a), Self::Stack(b)) => a == b,
(Self::Ref(a), Self::Ref(b)) => a.parsed == b.parsed,
_ => false,
}
}
}
impl Eq for FontStack {}
impl Hash for FontStack {
fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
match self {
Self::Stack(s) => s.hash(state),
Self::Ref(r) => (r.parsed as usize).hash(state),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FontHash {
pub font_hash: u64,
}
impl FontHash {
#[must_use] pub const fn invalid() -> Self {
Self { font_hash: 0 }
}
#[must_use] pub const fn from_hash(font_hash: u64) -> Self {
Self { font_hash }
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum FontStyle {
Normal,
Italic,
Oblique,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SegmentAlignment {
#[default]
First,
Total,
}
#[derive(Copy, Debug, Clone)]
pub struct VerticalMetrics {
pub advance: f32,
pub bearing_x: f32,
pub bearing_y: f32,
pub origin_y: f32,
}
#[derive(Copy, Debug, Clone)]
pub struct LayoutFontMetrics {
pub ascent: f32,
pub descent: f32,
pub line_gap: f32,
pub units_per_em: u16,
pub x_height: Option<f32>,
pub cap_height: Option<f32>,
}
impl LayoutFontMetrics {
#[must_use] pub fn baseline_scaled(&self, font_size: f32) -> f32 {
let scale = font_size / f32::from(self.units_per_em);
self.ascent * scale
}
#[must_use] pub fn x_height_scaled(&self, font_size: f32) -> f32 {
let scale = font_size / f32::from(self.units_per_em);
self.x_height.map_or(font_size * 0.5, |xh| xh * scale)
}
#[must_use] pub fn cap_height_scaled(&self, font_size: f32) -> f32 {
let scale = font_size / f32::from(self.units_per_em);
self.cap_height.unwrap_or(self.ascent) * scale
}
#[must_use] pub fn from_font_metrics(metrics: &azul_css::props::basic::FontMetrics) -> Self {
let ascent = metrics.s_typo_ascender
.as_option()
.map_or_else(|| f32::from(metrics.ascender), |v| f32::from(*v));
let descent = metrics.s_typo_descender
.as_option()
.map_or_else(|| f32::from(metrics.descender), |v| f32::from(*v));
let line_gap = metrics.s_typo_line_gap
.as_option()
.map_or_else(|| f32::from(metrics.line_gap), |v| f32::from(*v))
.max(0.0);
let x_height = metrics.sx_height
.as_option()
.map(|v| f32::from(*v));
let cap_height = metrics.s_cap_height
.as_option()
.map(|v| f32::from(*v));
Self {
ascent,
descent,
line_gap,
units_per_em: metrics.units_per_em,
x_height,
cap_height,
}
}
#[must_use] pub fn em_over(&self) -> f32 {
let central = self.central_baseline();
central + (f32::from(self.units_per_em) / 2.0)
}
#[must_use] pub fn em_under(&self) -> f32 {
let central = self.central_baseline();
central - (f32::from(self.units_per_em) / 2.0)
}
#[must_use] pub const fn central_baseline(&self) -> f32 {
f32::midpoint(self.ascent, self.descent)
}
}
#[derive(Copy, Debug, Clone)]
pub struct LineSegment {
pub start_x: f32,
pub width: f32,
pub priority: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum TextWrap {
#[default]
Wrap,
Balance,
NoWrap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum OverflowWrap {
#[default]
Normal,
Anywhere,
BreakWord,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Hyphens {
None,
#[default]
Manual,
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum WhiteSpaceMode {
#[default]
Normal,
Nowrap,
Pre,
PreWrap,
PreLine,
BreakSpaces,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum LineBreakStrictness {
#[default]
Auto,
Loose,
Normal,
Strict,
Anywhere,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub enum WordBreak {
#[default]
Normal,
BreakAll,
KeepAll,
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct InitialLetter {
pub size: f32,
pub sink: u32,
pub count: NonZeroUsize,
pub align: InitialLetterAlign,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum InitialLetterAlign {
Auto,
Alphabetic,
Hanging,
Ideographic,
}
impl Eq for InitialLetter {}
impl Hash for InitialLetter {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
(self.size.round() as isize).hash(state);
self.sink.hash(state);
self.count.hash(state);
self.align.hash(state);
}
}
#[derive(Copy, Debug, Clone, PartialOrd)]
pub enum PathSegment {
MoveTo(Point),
LineTo(Point),
CurveTo {
control1: Point,
control2: Point,
end: Point,
},
QuadTo {
control: Point,
end: Point,
},
Arc {
center: Point,
radius: f32,
start_angle: f32,
end_angle: f32,
},
Close,
}
impl Hash for PathSegment {
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::match_same_arms)] fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
match self {
Self::MoveTo(p) => p.hash(state),
Self::LineTo(p) => p.hash(state),
Self::CurveTo {
control1,
control2,
end,
} => {
control1.hash(state);
control2.hash(state);
end.hash(state);
}
Self::QuadTo { control, end } => {
control.hash(state);
end.hash(state);
}
Self::Arc {
center,
radius,
start_angle,
end_angle,
} => {
center.hash(state);
(radius.round() as isize).hash(state);
(start_angle.round() as isize).hash(state);
(end_angle.round() as isize).hash(state);
}
Self::Close => {} }
}
}
impl PartialEq for PathSegment {
#[allow(clippy::similar_names)] #[allow(clippy::match_same_arms)] fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::MoveTo(a), Self::MoveTo(b)) => a == b,
(Self::LineTo(a), Self::LineTo(b)) => a == b,
(
Self::CurveTo {
control1: c1a,
control2: c2a,
end: ea,
},
Self::CurveTo {
control1: c1b,
control2: c2b,
end: eb,
},
) => c1a == c1b && c2a == c2b && ea == eb,
(
Self::QuadTo {
control: ca,
end: ea,
},
Self::QuadTo {
control: cb,
end: eb,
},
) => ca == cb && ea == eb,
(
Self::Arc {
center: ca,
radius: ra,
start_angle: sa_a,
end_angle: ea_a,
},
Self::Arc {
center: cb,
radius: rb,
start_angle: sa_b,
end_angle: ea_b,
},
) => ca == cb && round_eq(*ra, *rb) && round_eq(*sa_a, *sa_b) && round_eq(*ea_a, *ea_b),
(Self::Close, Self::Close) => true,
_ => false, }
}
}
impl Eq for PathSegment {}
#[derive(Debug, Clone, Hash)]
#[repr(C, u8)]
pub enum InlineContent {
Text(StyledRun),
Image(InlineImage),
Shape(InlineShape),
Space(InlineSpace),
LineBreak(InlineBreak),
Tab {
style: Arc<StyleProperties>,
},
Marker {
run: StyledRun,
position_outside: bool,
},
Ruby {
base: Vec<InlineContent>,
text: Vec<InlineContent>,
style: Arc<StyleProperties>,
},
}
#[derive(Debug, Clone)]
pub struct InlineImage {
pub source: ImageSource,
pub intrinsic_size: Size,
pub display_size: Option<Size>,
pub baseline_offset: f32,
pub alignment: VerticalAlign,
pub object_fit: ObjectFit,
}
impl PartialEq for InlineImage {
fn eq(&self, other: &Self) -> bool {
self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
&& self.source == other.source
&& self.intrinsic_size == other.intrinsic_size
&& self.display_size == other.display_size
&& self.alignment == other.alignment
&& self.object_fit == other.object_fit
}
}
impl Eq for InlineImage {}
impl Hash for InlineImage {
fn hash<H: Hasher>(&self, state: &mut H) {
self.source.hash(state);
self.intrinsic_size.hash(state);
self.display_size.hash(state);
self.baseline_offset.to_bits().hash(state);
self.alignment.hash(state);
self.object_fit.hash(state);
}
}
impl PartialOrd for InlineImage {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for InlineImage {
fn cmp(&self, other: &Self) -> Ordering {
self.source
.cmp(&other.source)
.then_with(|| self.intrinsic_size.cmp(&other.intrinsic_size))
.then_with(|| self.display_size.cmp(&other.display_size))
.then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
.then_with(|| self.alignment.cmp(&other.alignment))
.then_with(|| self.object_fit.cmp(&other.object_fit))
}
}
#[derive(Debug, Clone)]
pub struct Glyph {
pub glyph_id: u16,
pub codepoint: char,
pub font_hash: u64,
pub font_metrics: LayoutFontMetrics,
pub style: Arc<StyleProperties>,
pub source: GlyphSource,
pub logical_byte_index: usize,
pub logical_byte_len: usize,
pub content_index: usize,
pub cluster: u32,
pub advance: f32,
pub kerning: f32,
pub offset: Point,
pub vertical_advance: f32,
pub vertical_origin_y: f32, pub vertical_bearing: Point,
pub orientation: GlyphOrientation,
pub script: Script,
pub bidi_level: BidiLevel,
}
impl Glyph {
#[inline]
fn bounds(&self) -> Rect {
Rect {
x: 0.0,
y: 0.0,
width: self.advance,
height: self.style.line_height.resolve_with_metrics(self.style.font_size_px, &self.font_metrics),
}
}
#[inline]
const fn character_class(&self) -> CharacterClass {
classify_character(self.codepoint as u32)
}
#[inline]
fn is_whitespace(&self) -> bool {
self.character_class() == CharacterClass::Space
}
#[inline]
fn can_justify(&self) -> bool {
!self.codepoint.is_whitespace() && self.character_class() != CharacterClass::Combining
}
#[inline]
const fn justification_priority(&self) -> u8 {
get_justification_priority(self.character_class())
}
#[inline]
const fn break_opportunity_after(&self) -> bool {
let is_whitespace = self.codepoint.is_whitespace();
let is_soft_hyphen = self.codepoint == '\u{00AD}';
let is_hyphen_minus = self.codepoint == '\u{002D}';
let is_hyphen = self.codepoint == '\u{2010}';
is_whitespace || is_soft_hyphen || is_hyphen_minus || is_hyphen
}
}
#[derive(Debug, Clone)]
pub(crate) struct TextRunInfo<'a> {
pub(crate) text: &'a str,
pub(crate) style: Arc<StyleProperties>,
pub(crate) logical_start: usize,
pub(crate) content_index: usize,
}
#[derive(Debug, Clone)]
pub enum ImageSource {
Ref(ImageRef),
Url(String),
Data(Arc<[u8]>),
Svg(Arc<str>),
Placeholder(Size),
}
impl PartialEq for ImageSource {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Ref(a), Self::Ref(b)) => a.get_hash() == b.get_hash(),
(Self::Url(a), Self::Url(b)) => a == b,
(Self::Data(a), Self::Data(b)) => Arc::ptr_eq(a, b),
(Self::Svg(a), Self::Svg(b)) => Arc::ptr_eq(a, b),
(Self::Placeholder(a), Self::Placeholder(b)) => {
a.width.to_bits() == b.width.to_bits() && a.height.to_bits() == b.height.to_bits()
}
_ => false,
}
}
}
impl Eq for ImageSource {}
impl Hash for ImageSource {
fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
match self {
Self::Ref(r) => r.get_hash().hash(state),
Self::Url(s) => s.hash(state),
Self::Data(d) => (Arc::as_ptr(d).cast::<u8>() as usize).hash(state),
Self::Svg(s) => (Arc::as_ptr(s).cast::<u8>() as usize).hash(state),
Self::Placeholder(sz) => {
sz.width.to_bits().hash(state);
sz.height.to_bits().hash(state);
}
}
}
}
impl PartialOrd for ImageSource {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ImageSource {
fn cmp(&self, other: &Self) -> Ordering {
const fn variant_index(s: &ImageSource) -> u8 {
match s {
ImageSource::Ref(_) => 0,
ImageSource::Url(_) => 1,
ImageSource::Data(_) => 2,
ImageSource::Svg(_) => 3,
ImageSource::Placeholder(_) => 4,
}
}
match (self, other) {
(Self::Ref(a), Self::Ref(b)) => a.get_hash().cmp(&b.get_hash()),
(Self::Url(a), Self::Url(b)) => a.cmp(b),
(Self::Data(a), Self::Data(b)) => {
(Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
}
(Self::Svg(a), Self::Svg(b)) => {
(Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
}
(Self::Placeholder(a), Self::Placeholder(b)) => {
(a.width.to_bits(), a.height.to_bits())
.cmp(&(b.width.to_bits(), b.height.to_bits()))
}
_ => variant_index(self).cmp(&variant_index(other)),
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd)]
pub enum VerticalAlign {
#[default]
Baseline,
Bottom,
Top,
Middle,
TextTop,
TextBottom,
Sub,
Super,
Offset(f32),
}
impl Hash for VerticalAlign {
fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
if let Self::Offset(f) = self {
f.to_bits().hash(state);
}
}
}
impl Eq for VerticalAlign {}
#[allow(clippy::derive_ord_xor_partial_ord)]
impl Ord for VerticalAlign {
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum ObjectFit {
Fill,
Contain,
Cover,
None,
ScaleDown,
}
#[derive(Copy, Debug, Clone, PartialEq)]
pub struct InlineBorderInfo {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
pub top_color: ColorU,
pub right_color: ColorU,
pub bottom_color: ColorU,
pub left_color: ColorU,
pub radius: Option<f32>,
pub padding_top: f32,
pub padding_right: f32,
pub padding_bottom: f32,
pub padding_left: f32,
pub is_first_fragment: bool,
pub is_last_fragment: bool,
pub is_rtl: bool,
}
impl Default for InlineBorderInfo {
fn default() -> Self {
Self {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
top_color: ColorU::TRANSPARENT,
right_color: ColorU::TRANSPARENT,
bottom_color: ColorU::TRANSPARENT,
left_color: ColorU::TRANSPARENT,
radius: None,
padding_top: 0.0,
padding_right: 0.0,
padding_bottom: 0.0,
padding_left: 0.0,
is_first_fragment: true,
is_last_fragment: true,
is_rtl: false,
}
}
}
impl InlineBorderInfo {
#[must_use] pub fn has_border(&self) -> bool {
self.top > 0.0 || self.right > 0.0 || self.bottom > 0.0 || self.left > 0.0
}
#[must_use] pub fn has_chrome(&self) -> bool {
self.has_border()
|| self.padding_top > 0.0
|| self.padding_right > 0.0
|| self.padding_bottom > 0.0
|| self.padding_left > 0.0
}
#[must_use] pub fn left_inset(&self) -> f32 {
let show = if self.is_rtl { self.is_last_fragment } else { self.is_first_fragment };
if show { self.left + self.padding_left } else { 0.0 }
}
#[must_use] pub fn right_inset(&self) -> f32 {
let show = if self.is_rtl { self.is_first_fragment } else { self.is_last_fragment };
if show { self.right + self.padding_right } else { 0.0 }
}
#[must_use] pub fn top_inset(&self) -> f32 { self.top + self.padding_top }
#[must_use] pub fn bottom_inset(&self) -> f32 { self.bottom + self.padding_bottom }
}
#[derive(Debug, Clone)]
pub struct InlineShape {
pub shape_def: ShapeDefinition,
pub fill: Option<ColorU>,
pub stroke: Option<Stroke>,
pub baseline_offset: f32,
pub alignment: VerticalAlign,
pub source_node_id: Option<NodeId>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OverflowBehavior {
Visible,
Hidden,
Scroll,
#[default]
Auto,
Break,
}
#[derive(Debug, Clone)]
pub(crate) struct MeasuredImage {
pub(crate) source: ImageSource,
pub(crate) size: Size,
pub(crate) baseline_offset: f32,
pub(crate) alignment: VerticalAlign,
pub(crate) content_index: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct MeasuredShape {
pub(crate) shape_def: ShapeDefinition,
pub(crate) size: Size,
pub(crate) baseline_offset: f32,
pub(crate) alignment: VerticalAlign,
pub(crate) content_index: usize,
}
#[derive(Copy, Debug, Clone)]
pub struct InlineSpace {
pub width: f32,
pub is_breaking: bool, pub is_stretchy: bool, }
impl PartialEq for InlineSpace {
fn eq(&self, other: &Self) -> bool {
self.width.to_bits() == other.width.to_bits()
&& self.is_breaking == other.is_breaking
&& self.is_stretchy == other.is_stretchy
}
}
impl Eq for InlineSpace {}
impl Hash for InlineSpace {
fn hash<H: Hasher>(&self, state: &mut H) {
self.width.to_bits().hash(state);
self.is_breaking.hash(state);
self.is_stretchy.hash(state);
}
}
impl PartialOrd for InlineSpace {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for InlineSpace {
fn cmp(&self, other: &Self) -> Ordering {
self.width
.total_cmp(&other.width)
.then_with(|| self.is_breaking.cmp(&other.is_breaking))
.then_with(|| self.is_stretchy.cmp(&other.is_stretchy))
}
}
impl PartialEq for InlineShape {
fn eq(&self, other: &Self) -> bool {
self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
&& self.shape_def == other.shape_def
&& self.fill == other.fill
&& self.stroke == other.stroke
&& self.alignment == other.alignment
&& self.source_node_id == other.source_node_id
}
}
impl Eq for InlineShape {}
impl Hash for InlineShape {
fn hash<H: Hasher>(&self, state: &mut H) {
self.shape_def.hash(state);
self.fill.hash(state);
self.stroke.hash(state);
self.baseline_offset.to_bits().hash(state);
self.alignment.hash(state);
self.source_node_id.hash(state);
}
}
impl PartialOrd for InlineShape {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(
self.shape_def
.partial_cmp(&other.shape_def)?
.then_with(|| self.fill.cmp(&other.fill))
.then_with(|| {
self.stroke
.partial_cmp(&other.stroke)
.unwrap_or(Ordering::Equal)
})
.then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
.then_with(|| self.alignment.cmp(&other.alignment))
.then_with(|| self.source_node_id.cmp(&other.source_node_id)),
)
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl PartialEq for Rect {
fn eq(&self, other: &Self) -> bool {
round_eq(self.x, other.x)
&& round_eq(self.y, other.y)
&& round_eq(self.width, other.width)
&& round_eq(self.height, other.height)
}
}
impl Eq for Rect {}
impl Hash for Rect {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
(self.x.round() as isize).hash(state);
(self.y.round() as isize).hash(state);
(self.width.round() as isize).hash(state);
(self.height.round() as isize).hash(state);
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Size {
pub width: f32,
pub height: f32,
}
impl PartialOrd for Size {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Size {
#[allow(clippy::cast_possible_truncation)] fn cmp(&self, other: &Self) -> Ordering {
(self.width.round() as isize)
.cmp(&(other.width.round() as isize))
.then_with(|| (self.height.round() as isize).cmp(&(other.height.round() as isize)))
}
}
impl Hash for Size {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
(self.width.round() as isize).hash(state);
(self.height.round() as isize).hash(state);
}
}
impl PartialEq for Size {
fn eq(&self, other: &Self) -> bool {
round_eq(self.width, other.width) && round_eq(self.height, other.height)
}
}
impl Eq for Size {}
impl Size {
#[must_use] pub const fn zero() -> Self {
Self::new(0.0, 0.0)
}
#[must_use] pub const fn new(width: f32, height: f32) -> Self {
Self { width, height }
}
}
#[derive(Debug, Default, Clone, Copy, PartialOrd)]
pub struct Point {
pub x: f32,
pub y: f32,
}
impl Hash for Point {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
(self.x.round() as isize).hash(state);
(self.y.round() as isize).hash(state);
}
}
impl PartialEq for Point {
fn eq(&self, other: &Self) -> bool {
round_eq(self.x, other.x) && round_eq(self.y, other.y)
}
}
impl Eq for Point {}
#[derive(Debug, Clone, PartialOrd)]
pub enum ShapeDefinition {
Rectangle {
size: Size,
corner_radius: Option<f32>,
},
Circle {
radius: f32,
},
Ellipse {
radii: Size,
},
Polygon {
points: Vec<Point>,
},
Path {
segments: Vec<PathSegment>,
},
}
impl Hash for ShapeDefinition {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
match self {
Self::Rectangle {
size,
corner_radius,
} => {
size.hash(state);
corner_radius.map(|r| r.round() as isize).hash(state);
}
Self::Circle { radius } => {
(radius.round() as isize).hash(state);
}
Self::Ellipse { radii } => {
radii.hash(state);
}
Self::Polygon { points } => {
points.hash(state);
}
Self::Path { segments } => {
segments.hash(state);
}
}
}
}
impl PartialEq for ShapeDefinition {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(
Self::Rectangle {
size: s1,
corner_radius: r1,
},
Self::Rectangle {
size: s2,
corner_radius: r2,
},
) => {
s1 == s2
&& match (r1, r2) {
(None, None) => true,
(Some(v1), Some(v2)) => round_eq(*v1, *v2),
_ => false,
}
}
(Self::Circle { radius: r1 }, Self::Circle { radius: r2 }) => {
round_eq(*r1, *r2)
}
(Self::Ellipse { radii: r1 }, Self::Ellipse { radii: r2 }) => {
r1 == r2
}
(Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
p1 == p2
}
(Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
s1 == s2
}
_ => false,
}
}
}
impl Eq for ShapeDefinition {}
impl ShapeDefinition {
#[must_use] pub fn get_size(&self) -> Size {
match self {
Self::Rectangle { size, .. } => *size,
Self::Circle { radius } => {
let diameter = radius * 2.0;
Size::new(diameter, diameter)
}
Self::Ellipse { radii } => Size::new(radii.width * 2.0, radii.height * 2.0),
Self::Polygon { points } => calculate_bounding_box_size(points),
Self::Path { segments } => {
let mut points = Vec::new();
let mut current_pos = Point { x: 0.0, y: 0.0 };
for segment in segments {
match segment {
PathSegment::MoveTo(p) | PathSegment::LineTo(p) => {
points.push(*p);
current_pos = *p;
}
PathSegment::QuadTo { control, end } => {
points.push(current_pos);
points.push(*control);
points.push(*end);
current_pos = *end;
}
PathSegment::CurveTo {
control1,
control2,
end,
} => {
points.push(current_pos);
points.push(*control1);
points.push(*control2);
points.push(*end);
current_pos = *end;
}
PathSegment::Arc {
center,
radius,
start_angle,
end_angle,
} => {
let start_point = Point {
x: center.x + radius * start_angle.cos(),
y: center.y + radius * start_angle.sin(),
};
let end_point = Point {
x: center.x + radius * end_angle.cos(),
y: center.y + radius * end_angle.sin(),
};
points.push(start_point);
points.push(end_point);
let mut normalized_end = *end_angle;
#[allow(clippy::while_float)] while normalized_end < *start_angle {
normalized_end += 2.0 * std::f32::consts::PI;
}
let mut check_angle = (*start_angle / std::f32::consts::FRAC_PI_2)
.ceil()
* std::f32::consts::FRAC_PI_2;
#[allow(clippy::while_float)] while check_angle < normalized_end {
points.push(Point {
x: center.x + radius * check_angle.cos(),
y: center.y + radius * check_angle.sin(),
});
check_angle += std::f32::consts::FRAC_PI_2;
}
current_pos = end_point;
}
PathSegment::Close => {
}
}
}
calculate_bounding_box_size(&points)
}
}
}
}
pub(crate) fn resolve_effective_alignment(
text_align: TextAlign,
text_align_last: TextAlign,
is_last_or_forced: bool,
) -> TextAlign {
if is_last_or_forced {
if text_align_last == TextAlign::default() {
if text_align == TextAlign::Justify { TextAlign::Start } else { text_align }
} else {
text_align_last
}
} else {
text_align
}
}
fn calculate_bounding_box_size(points: &[Point]) -> Size {
if points.is_empty() {
return Size::zero();
}
let mut min_x = f32::MAX;
let mut max_x = f32::MIN;
let mut min_y = f32::MAX;
let mut max_y = f32::MIN;
for point in points {
min_x = min_x.min(point.x);
max_x = max_x.max(point.x);
min_y = min_y.min(point.y);
max_y = max_y.max(point.y);
}
if min_x > max_x || min_y > max_y {
return Size::zero();
}
Size::new(max_x - min_x, max_y - min_y)
}
#[derive(Debug, Clone, PartialOrd)]
pub struct Stroke {
pub color: ColorU,
pub width: f32,
pub dash_pattern: Option<Vec<f32>>,
}
impl Hash for Stroke {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
self.color.hash(state);
(self.width.round() as isize).hash(state);
match &self.dash_pattern {
None => 0u8.hash(state), Some(pattern) => {
1u8.hash(state); pattern.len().hash(state); for &val in pattern {
(val.round() as isize).hash(state); }
}
}
}
}
impl PartialEq for Stroke {
fn eq(&self, other: &Self) -> bool {
if self.color != other.color || !round_eq(self.width, other.width) {
return false;
}
match (&self.dash_pattern, &other.dash_pattern) {
(None, None) => true,
(Some(p1), Some(p2)) => {
p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(a, b)| round_eq(*a, *b))
}
_ => false,
}
}
}
impl Eq for Stroke {}
#[allow(clippy::cast_possible_truncation)] fn round_eq(a: f32, b: f32) -> bool {
(a.round() as isize) == (b.round() as isize)
}
#[derive(Debug, Clone)]
pub enum ShapeBoundary {
Rectangle(Rect),
Circle { center: Point, radius: f32 },
Ellipse { center: Point, radii: Size },
Polygon { points: Vec<Point> },
Path { segments: Vec<PathSegment> },
}
impl ShapeBoundary {
#[allow(clippy::suboptimal_flops)] #[must_use] pub fn inflate(&self, margin: f32) -> Self {
if margin == 0.0 {
return self.clone();
}
match self {
Self::Rectangle(rect) => Self::Rectangle(Rect {
x: rect.x - margin,
y: rect.y - margin,
width: (rect.width + margin * 2.0).max(0.0),
height: (rect.height + margin * 2.0).max(0.0),
}),
Self::Circle { center, radius } => Self::Circle {
center: *center,
radius: radius + margin,
},
_ => self.clone(),
}
}
}
impl Hash for ShapeBoundary {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
match self {
Self::Rectangle(rect) => rect.hash(state),
Self::Circle { center, radius } => {
center.hash(state);
(radius.round() as isize).hash(state);
}
Self::Ellipse { center, radii } => {
center.hash(state);
radii.hash(state);
}
Self::Polygon { points } => points.hash(state),
Self::Path { segments } => segments.hash(state),
}
}
}
impl PartialEq for ShapeBoundary {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Rectangle(r1), Self::Rectangle(r2)) => r1 == r2,
(
Self::Circle {
center: c1,
radius: r1,
},
Self::Circle {
center: c2,
radius: r2,
},
) => c1 == c2 && round_eq(*r1, *r2),
(
Self::Ellipse {
center: c1,
radii: r1,
},
Self::Ellipse {
center: c2,
radii: r2,
},
) => c1 == c2 && r1 == r2,
(Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
p1 == p2
}
(Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
s1 == s2
}
_ => false,
}
}
}
impl Eq for ShapeBoundary {}
impl ShapeBoundary {
#[allow(clippy::too_many_lines)] pub fn from_css_shape(
css_shape: &azul_css::shape::CssShape,
reference_box: Rect,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Self {
use azul_css::shape::CssShape;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Input CSS shape: {css_shape:?}"
)));
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Reference box: {reference_box:?}"
)));
}
let result = match css_shape {
CssShape::Circle(circle) => {
let center = Point {
x: reference_box.x + circle.center.x,
y: reference_box.y + circle.center.y,
};
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Circle - CSS center: ({}, {}), radius: {}",
circle.center.x, circle.center.y, circle.radius
)));
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Circle - Absolute center: ({}, {}), \
radius: {}",
center.x, center.y, circle.radius
)));
}
Self::Circle {
center,
radius: circle.radius,
}
}
CssShape::Ellipse(ellipse) => {
let center = Point {
x: reference_box.x + ellipse.center.x,
y: reference_box.y + ellipse.center.y,
};
let radii = Size {
width: ellipse.radius_x,
height: ellipse.radius_y,
};
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Ellipse - center: ({}, {}), radii: ({}, \
{})",
center.x, center.y, radii.width, radii.height
)));
}
Self::Ellipse { center, radii }
}
CssShape::Polygon(polygon) => {
let points = polygon
.points
.as_ref()
.iter()
.map(|pt| Point {
x: reference_box.x + pt.x,
y: reference_box.y + pt.y,
})
.collect();
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Polygon - {} points",
polygon.points.as_ref().len()
)));
}
Self::Polygon { points }
}
CssShape::Inset(inset) => {
let x = reference_box.x + inset.inset_left;
let y = reference_box.y + inset.inset_top;
let width = reference_box.width - inset.inset_left - inset.inset_right;
let height = reference_box.height - inset.inset_top - inset.inset_bottom;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Inset - insets: ({}, {}, {}, {})",
inset.inset_top, inset.inset_right, inset.inset_bottom, inset.inset_left
)));
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Inset - resulting rect: x={x}, y={y}, \
w={width}, h={height}"
)));
}
Self::Rectangle(Rect {
x,
y,
width: width.max(0.0),
height: height.max(0.0),
})
}
CssShape::Path(path) => {
let segments = azul_core::path_parser::parse_svg_path_d(path.data.as_str())
.map_or_else(|_| Vec::new(), |multipolygon| {
flatten_svg_to_path_segments(&multipolygon, reference_box)
});
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Path - parsed {} flattened segments",
segments.len()
)));
}
if segments.is_empty() {
Self::Rectangle(reference_box)
} else {
Self::Path { segments }
}
}
};
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[ShapeBoundary::from_css_shape] Result: {result:?}"
)));
}
result
}
}
#[derive(Copy, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct InlineBreak {
pub break_type: BreakType,
pub clear: ClearType,
pub content_index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BreakType {
Soft, Hard, Page, Column, }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ClearType {
None,
Left,
Right,
Both,
}
#[derive(Debug, Clone)]
pub(crate) struct ShapeConstraints {
pub(crate) boundaries: Vec<ShapeBoundary>,
pub(crate) exclusions: Vec<ShapeBoundary>,
pub(crate) writing_mode: WritingMode,
pub(crate) text_align: TextAlign,
pub(crate) line_height: LineHeight,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
pub enum WritingMode {
#[default]
HorizontalTb, VerticalRl, VerticalLr, SidewaysRl, SidewaysLr, }
impl WritingMode {
#[must_use] pub const fn is_advance_horizontal(&self) -> bool {
matches!(
self,
Self::HorizontalTb | Self::SidewaysRl | Self::SidewaysLr
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
pub enum JustifyContent {
#[default]
None,
InterWord, InterCharacter, Distribute, Kashida, }
#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
pub enum TextAlign {
#[default]
Left,
Right,
Center,
Justify,
Start,
End, JustifyAll, }
#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
pub enum TextOrientation {
#[default]
Mixed, Upright, Sideways, }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Default)]
pub struct TextDecoration {
pub underline: bool,
pub strikethrough: bool,
pub overline: bool,
}
impl TextDecoration {
#[must_use] pub fn from_css(css: azul_css::props::style::text::StyleTextDecoration) -> Self {
use azul_css::props::style::text::StyleTextDecoration;
match css {
StyleTextDecoration::None => Self::default(),
StyleTextDecoration::Underline => Self {
underline: true,
strikethrough: false,
overline: false,
},
StyleTextDecoration::Overline => Self {
underline: false,
strikethrough: false,
overline: true,
},
StyleTextDecoration::LineThrough => Self {
underline: false,
strikethrough: true,
overline: false,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum TextTransform {
#[default]
None,
Uppercase,
Lowercase,
Capitalize,
FullWidth,
}
pub type FourCc = [u8; 4];
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub enum Spacing {
Px(i32), PxF(f32),
Em(f32),
}
impl Eq for Spacing {}
impl Hash for Spacing {
fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
match self {
Self::Px(val) => val.hash(state),
Self::PxF(val) | Self::Em(val) => val.to_bits().hash(state),
}
}
}
impl Default for Spacing {
fn default() -> Self {
Self::Px(0)
}
}
impl Spacing {
#[allow(clippy::cast_precision_loss)] #[must_use]
pub fn resolve_px(self, font_size_px: f32) -> f32 {
match self {
Self::Px(px) => px as f32,
Self::PxF(px) => px,
Self::Em(em) => em * font_size_px,
}
}
}
impl Default for FontHash {
fn default() -> Self {
Self::invalid()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StyleProperties {
pub font_stack: FontStack,
pub font_size_px: f32,
pub color: ColorU,
pub background_color: Option<ColorU>,
pub background_content: Vec<StyleBackgroundContent>,
pub border: Option<InlineBorderInfo>,
pub letter_spacing: Spacing,
pub word_spacing: Spacing,
pub line_height: LineHeight,
pub text_decoration: TextDecoration,
pub font_features: Vec<String>,
pub font_variations: Vec<(FourCc, f32)>,
pub tab_size: f32,
pub text_transform: TextTransform,
pub writing_mode: WritingMode,
pub text_orientation: TextOrientation,
pub text_combine_upright: Option<TextCombineUpright>,
pub font_variant_caps: FontVariantCaps,
pub font_variant_numeric: FontVariantNumeric,
pub font_variant_ligatures: FontVariantLigatures,
pub font_variant_east_asian: FontVariantEastAsian,
pub vertical_align: VerticalAlign,
}
impl Default for StyleProperties {
fn default() -> Self {
const FONT_SIZE: f32 = 16.0;
const TAB_SIZE: f32 = 8.0;
Self {
font_stack: FontStack::default(),
font_size_px: FONT_SIZE,
color: ColorU::default(),
background_color: None,
background_content: Vec::new(),
border: None,
letter_spacing: Spacing::default(), word_spacing: Spacing::default(), line_height: LineHeight::Normal,
text_decoration: TextDecoration::default(),
font_features: Vec::new(),
font_variations: Vec::new(),
tab_size: TAB_SIZE, text_transform: TextTransform::default(),
writing_mode: WritingMode::default(),
text_orientation: TextOrientation::default(),
text_combine_upright: None,
font_variant_caps: FontVariantCaps::default(),
font_variant_numeric: FontVariantNumeric::default(),
font_variant_ligatures: FontVariantLigatures::default(),
font_variant_east_asian: FontVariantEastAsian::default(),
vertical_align: VerticalAlign::Baseline,
}
}
}
impl Hash for StyleProperties {
#[allow(clippy::cast_possible_truncation)] fn hash<H: Hasher>(&self, state: &mut H) {
self.font_stack.hash(state);
self.color.hash(state);
self.background_color.hash(state);
self.text_decoration.hash(state);
self.font_features.hash(state);
self.writing_mode.hash(state);
self.text_orientation.hash(state);
self.text_combine_upright.hash(state);
self.vertical_align.hash(state);
self.letter_spacing.hash(state);
self.word_spacing.hash(state);
(self.font_size_px.round() as isize).hash(state);
self.line_height.hash(state);
}
}
impl StyleProperties {
#[allow(clippy::cast_possible_truncation)] #[must_use] pub fn layout_hash(&self) -> u64 {
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
self.font_stack.hash(&mut hasher);
self.font_size_px.to_bits().hash(&mut hasher);
self.font_features.hash(&mut hasher);
for (tag, value) in &self.font_variations {
tag.hash(&mut hasher);
(value.round() as i32).hash(&mut hasher);
}
self.letter_spacing.hash(&mut hasher);
self.word_spacing.hash(&mut hasher);
self.line_height.hash(&mut hasher);
(self.tab_size.round() as isize).hash(&mut hasher);
self.writing_mode.hash(&mut hasher);
self.text_orientation.hash(&mut hasher);
self.text_combine_upright.hash(&mut hasher);
self.text_transform.hash(&mut hasher);
self.font_variant_caps.hash(&mut hasher);
self.font_variant_numeric.hash(&mut hasher);
self.font_variant_ligatures.hash(&mut hasher);
self.font_variant_east_asian.hash(&mut hasher);
hasher.finish()
}
#[must_use] pub fn layout_eq(&self, other: &Self) -> bool {
self.layout_hash() == other.layout_hash()
}
}
#[derive(Copy, Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
pub enum TextCombineUpright {
None,
All, Digits(u8), }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GlyphSource {
Char,
Hyphen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CharacterClass {
Space, Punctuation, Letter, Ideograph, Symbol, Combining, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlyphOrientation {
Horizontal, Vertical, Upright, Mixed, }
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BidiDirection {
Ltr,
Rtl,
}
impl BidiDirection {
#[must_use] pub const fn is_rtl(&self) -> bool {
matches!(self, Self::Rtl)
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Default)]
pub enum UnicodeBidi {
#[default]
Normal,
Embed,
Isolate,
BidiOverride,
IsolateOverride,
Plaintext,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantCaps {
#[default]
Normal,
SmallCaps,
AllSmallCaps,
PetiteCaps,
AllPetiteCaps,
Unicase,
TitlingCaps,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantNumeric {
#[default]
Normal,
LiningNums,
OldstyleNums,
ProportionalNums,
TabularNums,
DiagonalFractions,
StackedFractions,
Ordinal,
SlashedZero,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantLigatures {
#[default]
Normal,
None,
Common,
NoCommon,
Discretionary,
NoDiscretionary,
Historical,
NoHistorical,
Contextual,
NoContextual,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantEastAsian {
#[default]
Normal,
Jis78,
Jis83,
Jis90,
Jis04,
Simplified,
Traditional,
FullWidth,
ProportionalWidth,
Ruby,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BidiLevel(u8);
impl BidiLevel {
#[must_use] pub const fn new(level: u8) -> Self {
Self(level)
}
#[must_use] pub const fn is_rtl(&self) -> bool {
self.0 % 2 == 1
}
#[must_use] pub const fn level(&self) -> u8 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct StyleOverride {
pub target: ContentIndex,
pub style: PartialStyleProperties,
}
#[derive(Debug, Clone, Default)]
pub struct PartialStyleProperties {
pub font_stack: Option<FontStack>,
pub font_size_px: Option<f32>,
pub color: Option<ColorU>,
pub letter_spacing: Option<Spacing>,
pub word_spacing: Option<Spacing>,
pub line_height: Option<LineHeight>,
pub text_decoration: Option<TextDecoration>,
pub font_features: Option<Vec<String>>,
pub font_variations: Option<Vec<(FourCc, f32)>>,
pub tab_size: Option<f32>,
pub text_transform: Option<TextTransform>,
pub writing_mode: Option<WritingMode>,
pub text_orientation: Option<TextOrientation>,
pub text_combine_upright: Option<Option<TextCombineUpright>>,
pub font_variant_caps: Option<FontVariantCaps>,
pub font_variant_numeric: Option<FontVariantNumeric>,
pub font_variant_ligatures: Option<FontVariantLigatures>,
pub font_variant_east_asian: Option<FontVariantEastAsian>,
}
impl Hash for PartialStyleProperties {
fn hash<H: Hasher>(&self, state: &mut H) {
self.font_stack.hash(state);
self.font_size_px.map(f32::to_bits).hash(state);
self.color.hash(state);
self.letter_spacing.hash(state);
self.word_spacing.hash(state);
self.line_height.hash(state);
self.text_decoration.hash(state);
self.font_features.hash(state);
if let Some(v) = self.font_variations.as_ref() {
for (tag, val) in v {
tag.hash(state);
val.to_bits().hash(state);
}
}
self.tab_size.map(f32::to_bits).hash(state);
self.text_transform.hash(state);
self.writing_mode.hash(state);
self.text_orientation.hash(state);
self.text_combine_upright.hash(state);
self.font_variant_caps.hash(state);
self.font_variant_numeric.hash(state);
self.font_variant_ligatures.hash(state);
self.font_variant_east_asian.hash(state);
}
}
impl PartialEq for PartialStyleProperties {
fn eq(&self, other: &Self) -> bool {
self.font_stack == other.font_stack &&
self.font_size_px.map(f32::to_bits) == other.font_size_px.map(f32::to_bits) &&
self.color == other.color &&
self.letter_spacing == other.letter_spacing &&
self.word_spacing == other.word_spacing &&
self.line_height == other.line_height &&
self.text_decoration == other.text_decoration &&
self.font_features == other.font_features &&
self.font_variations == other.font_variations && self.tab_size.map(f32::to_bits) == other.tab_size.map(f32::to_bits) &&
self.text_transform == other.text_transform &&
self.writing_mode == other.writing_mode &&
self.text_orientation == other.text_orientation &&
self.text_combine_upright == other.text_combine_upright &&
self.font_variant_caps == other.font_variant_caps &&
self.font_variant_numeric == other.font_variant_numeric &&
self.font_variant_ligatures == other.font_variant_ligatures &&
self.font_variant_east_asian == other.font_variant_east_asian
}
}
impl Eq for PartialStyleProperties {}
impl StyleProperties {
fn apply_override(&self, partial: &PartialStyleProperties) -> Self {
let mut new_style = self.clone();
if let Some(val) = &partial.font_stack {
new_style.font_stack = val.clone();
}
if let Some(val) = partial.font_size_px {
new_style.font_size_px = val;
}
if let Some(val) = &partial.color {
new_style.color = *val;
}
if let Some(val) = partial.letter_spacing {
new_style.letter_spacing = val;
}
if let Some(val) = partial.word_spacing {
new_style.word_spacing = val;
}
if let Some(val) = partial.line_height {
new_style.line_height = val;
}
if let Some(val) = &partial.text_decoration {
new_style.text_decoration = *val;
}
if let Some(val) = &partial.font_features {
new_style.font_features.clone_from(val);
}
if let Some(val) = &partial.font_variations {
new_style.font_variations.clone_from(val);
}
if let Some(val) = partial.tab_size {
new_style.tab_size = val;
}
if let Some(val) = partial.text_transform {
new_style.text_transform = val;
}
if let Some(val) = partial.writing_mode {
new_style.writing_mode = val;
}
if let Some(val) = partial.text_orientation {
new_style.text_orientation = val;
}
if let Some(val) = &partial.text_combine_upright {
new_style.text_combine_upright.clone_from(val);
}
if let Some(val) = partial.font_variant_caps {
new_style.font_variant_caps = val;
}
if let Some(val) = partial.font_variant_numeric {
new_style.font_variant_numeric = val;
}
if let Some(val) = partial.font_variant_ligatures {
new_style.font_variant_ligatures = val;
}
if let Some(val) = partial.font_variant_east_asian {
new_style.font_variant_east_asian = val;
}
new_style
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GlyphKind {
Character,
Hyphen,
NotDef,
Kashida {
width: f32,
},
}
#[derive(Debug, Clone)]
#[repr(C, u8)]
pub enum LogicalItem {
Text {
source: ContentIndex,
text: String,
style: Arc<StyleProperties>,
marker_position_outside: Option<bool>,
source_node_id: Option<NodeId>,
},
CombinedText {
source: ContentIndex,
text: String,
style: Arc<StyleProperties>,
},
Ruby {
source: ContentIndex,
base_text: String,
ruby_text: String,
style: Arc<StyleProperties>,
},
Object {
source: ContentIndex,
content: InlineContent,
},
Tab {
source: ContentIndex,
style: Arc<StyleProperties>,
},
Break {
source: ContentIndex,
break_info: InlineBreak,
},
}
impl Hash for LogicalItem {
fn hash<H: Hasher>(&self, state: &mut H) {
discriminant(self).hash(state);
match self {
Self::Text {
source,
text,
style,
marker_position_outside,
source_node_id,
} => {
source.hash(state);
text.hash(state);
style.as_ref().hash(state); marker_position_outside.hash(state);
source_node_id.hash(state);
}
Self::CombinedText {
source,
text,
style,
} => {
source.hash(state);
text.hash(state);
style.as_ref().hash(state);
}
Self::Ruby {
source,
base_text,
ruby_text,
style,
} => {
source.hash(state);
base_text.hash(state);
ruby_text.hash(state);
style.as_ref().hash(state);
}
Self::Object { source, content } => {
source.hash(state);
content.hash(state);
}
Self::Tab { source, style } => {
source.hash(state);
style.as_ref().hash(state);
}
Self::Break { source, break_info } => {
source.hash(state);
break_info.hash(state);
}
}
}
}
#[derive(Debug, Clone)]
pub struct VisualItem {
pub logical_source: LogicalItem,
pub bidi_level: BidiLevel,
pub script: Script,
pub text: String,
pub run_byte_offset: usize,
}
#[derive(Debug, Clone)]
#[repr(C, u8)]
pub enum ShapedItem {
Cluster(ShapedCluster),
CombinedBlock {
source: ContentIndex,
glyphs: ShapedGlyphVec,
bounds: Rect,
baseline_offset: f32,
},
Object {
source: ContentIndex,
bounds: Rect,
baseline_offset: f32,
content: InlineContent,
},
Tab {
source: ContentIndex,
bounds: Rect,
},
Break {
source: ContentIndex,
break_info: InlineBreak,
},
}
impl ShapedItem {
#[must_use] pub const fn as_cluster(&self) -> Option<&ShapedCluster> {
match self {
Self::Cluster(c) => Some(c),
_ => None,
}
}
#[allow(clippy::match_same_arms)] #[must_use] pub fn bounds(&self) -> Rect {
match self {
Self::Cluster(cluster) => {
let width = cluster.advance;
let (ascent, descent) = get_item_vertical_metrics_approx(self);
let height = ascent + descent;
Rect {
x: 0.0,
y: 0.0,
width,
height,
}
}
Self::CombinedBlock { bounds, .. } => *bounds,
Self::Object { bounds, .. } => *bounds,
Self::Tab { bounds, .. } => *bounds,
Self::Break { .. } => Rect::default(), }
}
}
#[derive(Debug, Clone)]
pub struct ShapedCluster {
pub text: String,
pub source_cluster_id: GraphemeClusterId,
pub source_content_index: ContentIndex,
pub source_node_id: Option<NodeId>,
pub glyphs: ShapedGlyphVec,
pub advance: f32,
pub direction: BidiDirection,
pub style: Arc<StyleProperties>,
pub marker_position_outside: Option<bool>,
pub is_first_fragment: bool,
pub is_last_fragment: bool,
}
#[derive(Debug, Clone)]
pub struct ShapedGlyph {
pub kind: GlyphKind,
pub glyph_id: u16,
pub cluster_offset: u32,
pub advance: f32,
pub kerning: f32,
pub offset: Point,
pub vertical_advance: f32,
pub vertical_offset: Point,
pub script: Script,
pub style: Arc<StyleProperties>,
pub font_hash: u64,
pub font_metrics: LayoutFontMetrics,
}
impl ShapedGlyph {
#[must_use] pub fn into_glyph_instance<T: ParsedFontTrait>(
&self,
writing_mode: WritingMode,
loaded_fonts: &LoadedFonts<T>,
) -> GlyphInstance {
let size = loaded_fonts
.get_by_hash(self.font_hash)
.and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
.unwrap_or_default();
let position = if writing_mode.is_advance_horizontal() {
LogicalPosition {
x: self.offset.x,
y: self.offset.y,
}
} else {
LogicalPosition {
x: self.vertical_offset.x,
y: self.vertical_offset.y,
}
};
GlyphInstance {
index: u32::from(self.glyph_id),
point: position,
size,
}
}
#[must_use] pub fn into_glyph_instance_at<T: ParsedFontTrait>(
&self,
writing_mode: WritingMode,
absolute_position: LogicalPosition,
loaded_fonts: &LoadedFonts<T>,
) -> GlyphInstance {
let size = loaded_fonts
.get_by_hash(self.font_hash)
.and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
.unwrap_or_default();
GlyphInstance {
index: u32::from(self.glyph_id),
point: absolute_position,
size,
}
}
#[must_use] pub fn into_glyph_instance_at_simple(
&self,
_writing_mode: WritingMode,
absolute_position: LogicalPosition,
) -> GlyphInstance {
GlyphInstance {
index: u32::from(self.glyph_id),
point: absolute_position,
size: LogicalSize::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct PositionedItem {
pub item: ShapedItem,
pub position: Point,
pub line_index: usize,
}
#[derive(Debug, Clone)]
pub struct UnifiedLayout {
pub items: Vec<PositionedItem>,
pub overflow: OverflowInfo,
}
impl UnifiedLayout {
#[must_use] pub fn bounds(&self) -> Rect {
if self.items.is_empty() {
return Rect::default();
}
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
for item in &self.items {
let item_x = item.position.x;
let item_y = item.position.y;
let item_bounds = item.item.bounds();
let item_width = item_bounds.width;
let item_height = item_bounds.height;
min_x = min_x.min(item_x);
min_y = min_y.min(item_y);
max_x = max_x.max(item_x + item_width);
max_y = max_y.max(item_y + item_height);
}
Rect {
x: min_x,
y: min_y,
width: max_x - min_x,
height: max_y - min_y,
}
}
#[must_use] pub const fn is_empty(&self) -> bool {
self.items.is_empty()
}
#[must_use] pub fn first_baseline(&self) -> Option<f32> {
self.items
.iter()
.find_map(|item| get_baseline_for_item(&item.item))
}
#[must_use] pub fn last_baseline(&self) -> Option<f32> {
self.items
.iter()
.rev()
.find_map(|item| get_baseline_for_item(&item.item))
}
#[allow(clippy::suboptimal_flops)] #[must_use] pub fn hittest_cursor(&self, point: LogicalPosition) -> Option<TextCursor> {
if self.items.is_empty() {
return None;
}
let mut closest_item_idx = 0;
let mut closest_distance = f32::MAX;
for (idx, item) in self.items.iter().enumerate() {
if !matches!(item.item, ShapedItem::Cluster(_)) {
continue;
}
let item_bounds = item.item.bounds();
let item_center_y = item.position.y + item_bounds.height / 2.0;
let vertical_distance = (point.y - item_center_y).abs();
let horizontal_distance = if point.x < item.position.x {
item.position.x - point.x
} else if point.x > item.position.x + item_bounds.width {
point.x - (item.position.x + item_bounds.width)
} else {
0.0 };
let distance = vertical_distance * 2.0 + horizontal_distance;
if distance < closest_distance {
closest_distance = distance;
closest_item_idx = idx;
}
}
let closest_item = &self.items[closest_item_idx];
let cluster = match &closest_item.item {
ShapedItem::Cluster(c) => c,
ShapedItem::Object { source, .. } | ShapedItem::CombinedBlock { source, .. } => {
return Some(TextCursor {
cluster_id: GraphemeClusterId {
source_run: source.run_index,
start_byte_in_run: source.item_index,
},
affinity: if point.x
< closest_item.position.x + (closest_item.item.bounds().width / 2.0)
{
CursorAffinity::Leading
} else {
CursorAffinity::Trailing
},
});
}
_ => return None,
};
let cluster_mid_x = closest_item.position.x + cluster.advance / 2.0;
let affinity = if point.x < cluster_mid_x {
CursorAffinity::Leading
} else {
CursorAffinity::Trailing
};
Some(TextCursor {
cluster_id: cluster.source_cluster_id,
affinity,
})
}
#[allow(clippy::too_many_lines)] #[must_use] pub fn get_selection_rects(&self, range: &SelectionRange) -> Vec<LogicalRect> {
let mut cluster_map: HashMap<GraphemeClusterId, &PositionedItem> = HashMap::new();
for item in &self.items {
if let Some(cluster) = item.item.as_cluster() {
cluster_map.insert(cluster.source_cluster_id, item);
}
}
let (start_cursor, end_cursor) = if range.start.cluster_id > range.end.cluster_id
|| (range.start.cluster_id == range.end.cluster_id
&& range.start.affinity > range.end.affinity)
{
(range.end, range.start)
} else {
(range.start, range.end)
};
let Some(start_item) = cluster_map.get(&start_cursor.cluster_id) else {
return Vec::new();
};
let Some(end_item) = cluster_map.get(&end_cursor.cluster_id) else {
return Vec::new();
};
let mut rects = Vec::new();
let get_cursor_x = |item: &PositionedItem, affinity: CursorAffinity| -> f32 {
let left = item.position.x;
let right = item.position.x + get_item_measure(&item.item, false);
let rtl = item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
match (affinity, rtl) {
(CursorAffinity::Leading, false) | (CursorAffinity::Trailing, true) => left,
(CursorAffinity::Trailing, false) | (CursorAffinity::Leading, true) => right,
}
};
let get_line_bounds = |line_index: usize| -> Option<LogicalRect> {
let items_on_line = self.items.iter().filter(|i| i.line_index == line_index);
let mut min_x: Option<f32> = None;
let mut max_x: Option<f32> = None;
let mut min_y: Option<f32> = None;
let mut max_y: Option<f32> = None;
for item in items_on_line {
let item_bounds = item.item.bounds();
if item_bounds.width <= 0.0 && item_bounds.height <= 0.0 {
continue;
}
let item_x_end = item.position.x + item_bounds.width;
let item_y_end = item.position.y + item_bounds.height;
min_x = Some(min_x.map_or(item.position.x, |mx| mx.min(item.position.x)));
max_x = Some(max_x.map_or(item_x_end, |mx| mx.max(item_x_end)));
min_y = Some(min_y.map_or(item.position.y, |my| my.min(item.position.y)));
max_y = Some(max_y.map_or(item_y_end, |my| my.max(item_y_end)));
}
if let (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) =
(min_x, max_x, min_y, max_y)
{
Some(LogicalRect {
origin: LogicalPosition { x: min_x, y: min_y },
size: LogicalSize {
width: max_x - min_x,
height: max_y - min_y,
},
})
} else {
None
}
};
if start_item.line_index == end_item.line_index {
if let Some(line_bounds) = get_line_bounds(start_item.line_index) {
let mut segments: Vec<(f32, f32, BidiDirection)> = Vec::new();
for item in &self.items {
if item.line_index != start_item.line_index {
continue;
}
let Some(c) = item.item.as_cluster() else {
continue;
};
let id = c.source_cluster_id;
let after_start = id > start_cursor.cluster_id
|| (id == start_cursor.cluster_id
&& start_cursor.affinity == CursorAffinity::Leading);
let before_end = id < end_cursor.cluster_id
|| (id == end_cursor.cluster_id
&& end_cursor.affinity == CursorAffinity::Trailing);
if !(after_start && before_end) {
continue;
}
let x0 = item.position.x;
let x1 = item.position.x + get_item_measure(&item.item, false);
let (lo, hi) = (x0.min(x1), x0.max(x1));
if let Some(last) = segments.last_mut() {
let contiguous = lo <= last.1 + 0.5 && hi >= last.0 - 0.5;
if last.2 == c.direction && contiguous {
last.0 = last.0.min(lo);
last.1 = last.1.max(hi);
continue;
}
}
segments.push((lo, hi, c.direction));
}
if segments.is_empty() {
let start_x = get_cursor_x(start_item, start_cursor.affinity);
let end_x = get_cursor_x(end_item, end_cursor.affinity);
rects.push(LogicalRect {
origin: LogicalPosition {
x: start_x.min(end_x),
y: line_bounds.origin.y,
},
size: LogicalSize {
width: (end_x - start_x).abs(),
height: line_bounds.size.height,
},
});
} else {
for (lo, hi, _dir) in segments {
rects.push(LogicalRect {
origin: LogicalPosition {
x: lo,
y: line_bounds.origin.y,
},
size: LogicalSize {
width: hi - lo,
height: line_bounds.size.height,
},
});
}
}
}
}
else {
if let Some(start_line_bounds) = get_line_bounds(start_item.line_index) {
let start_x = get_cursor_x(start_item, start_cursor.affinity);
let line_left = start_line_bounds.origin.x;
let line_right = start_line_bounds.origin.x + start_line_bounds.size.width;
let rtl = start_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
let (lo, hi) = if rtl { (line_left, start_x) } else { (start_x, line_right) };
rects.push(LogicalRect {
origin: LogicalPosition {
x: lo,
y: start_line_bounds.origin.y,
},
size: LogicalSize {
width: hi - lo,
height: start_line_bounds.size.height,
},
});
}
for line_idx in (start_item.line_index + 1)..end_item.line_index {
if let Some(line_bounds) = get_line_bounds(line_idx) {
rects.push(line_bounds);
}
}
if let Some(end_line_bounds) = get_line_bounds(end_item.line_index) {
let end_x = get_cursor_x(end_item, end_cursor.affinity);
let line_left = end_line_bounds.origin.x;
let line_right = end_line_bounds.origin.x + end_line_bounds.size.width;
let rtl = end_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
let (lo, hi) = if rtl { (end_x, line_right) } else { (line_left, end_x) };
rects.push(LogicalRect {
origin: LogicalPosition {
x: lo,
y: end_line_bounds.origin.y,
},
size: LogicalSize {
width: hi - lo,
height: end_line_bounds.size.height,
},
});
}
}
rects
}
#[must_use] pub fn get_cursor_rect(&self, cursor: &TextCursor) -> Option<LogicalRect> {
let mut last_cluster: Option<(&PositionedItem, &ShapedCluster)> = None;
for item in &self.items {
if let ShapedItem::Cluster(cluster) = &item.item {
if cluster.source_cluster_id == cursor.cluster_id {
let line_height = item.item.bounds().height;
let (lead_x, trail_x) = if cluster.direction.is_rtl() {
(item.position.x + cluster.advance, item.position.x)
} else {
(item.position.x, item.position.x + cluster.advance)
};
let cursor_x = match cursor.affinity {
CursorAffinity::Leading => lead_x,
CursorAffinity::Trailing => trail_x,
};
return Some(LogicalRect {
origin: LogicalPosition {
x: cursor_x,
y: item.position.y,
},
size: LogicalSize {
width: 1.0,
height: line_height,
},
});
}
last_cluster = Some((item, cluster));
}
}
if let Some((item, cluster)) = last_cluster {
if cursor.cluster_id.source_run == cluster.source_cluster_id.source_run
&& cursor.cluster_id.start_byte_in_run >= cluster.source_cluster_id.start_byte_in_run
{
let line_height = item.item.bounds().height;
let past_end_x = if cluster.direction.is_rtl() {
item.position.x
} else {
item.position.x + cluster.advance
};
return Some(LogicalRect {
origin: LogicalPosition {
x: past_end_x,
y: item.position.y,
},
size: LogicalSize {
width: 1.0,
height: line_height,
},
});
}
}
None
}
#[must_use] pub fn get_first_cluster_cursor(&self) -> Option<TextCursor> {
for item in &self.items {
if let ShapedItem::Cluster(cluster) = &item.item {
return Some(TextCursor {
cluster_id: cluster.source_cluster_id,
affinity: CursorAffinity::Leading,
});
}
}
None
}
#[must_use] pub fn get_last_cluster_cursor(&self) -> Option<TextCursor> {
for item in self.items.iter().rev() {
if let ShapedItem::Cluster(cluster) = &item.item {
return Some(TextCursor {
cluster_id: cluster.source_cluster_id,
affinity: CursorAffinity::Trailing,
});
}
}
None
}
fn grapheme_stops(&self) -> Vec<GraphemeClusterId> {
let mut stops: Vec<(GraphemeClusterId, &str)> = self
.items
.iter()
.filter_map(|it| {
it.item
.as_cluster()
.map(|c| (c.source_cluster_id, c.text.as_str()))
})
.collect();
stops.sort_by(|a, b| {
(a.0.source_run, a.0.start_byte_in_run).cmp(&(b.0.source_run, b.0.start_byte_in_run))
});
stops.dedup_by_key(|(id, _)| *id);
stops
.into_iter()
.filter(|(_, text)| !Self::cluster_is_grapheme_continuation(text))
.map(|(id, _)| id)
.collect()
}
fn cluster_is_grapheme_continuation(text: &str) -> bool {
let Some(first) = text.chars().next() else {
return false;
};
let mut probe = String::with_capacity(1 + first.len_utf8());
probe.push('x');
probe.push(first);
probe.graphemes(true).count() == 1
}
fn grapheme_caret_offset(stops: &[GraphemeClusterId], cursor: &TextCursor) -> Option<usize> {
let trailing = usize::from(cursor.affinity == CursorAffinity::Trailing);
if let Some(idx) = stops.iter().position(|id| *id == cursor.cluster_id) {
return Some(idx + trailing);
}
let key = (cursor.cluster_id.source_run, cursor.cluster_id.start_byte_in_run);
let idx = stops
.iter()
.rposition(|id| (id.source_run, id.start_byte_in_run) <= key)?;
Some(idx + trailing)
}
fn cursor_from_grapheme_offset(stops: &[GraphemeClusterId], offset: usize) -> TextCursor {
let n = stops.len();
if offset >= n {
TextCursor { cluster_id: stops[n - 1], affinity: CursorAffinity::Trailing }
} else {
TextCursor { cluster_id: stops[offset], affinity: CursorAffinity::Leading }
}
}
pub fn move_cursor_left(
&self,
cursor: TextCursor,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
let stops = self.grapheme_stops();
if stops.is_empty() {
return cursor;
}
let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
return cursor;
};
let moved = Self::cursor_from_grapheme_offset(&stops, offset.saturating_sub(1));
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_left: byte {} -> byte {}",
cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
));
}
moved
}
pub fn move_cursor_right(
&self,
cursor: TextCursor,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
let stops = self.grapheme_stops();
if stops.is_empty() {
return cursor;
}
let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
return cursor;
};
let moved = Self::cursor_from_grapheme_offset(&stops, (offset + 1).min(stops.len()));
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_right: byte {} -> byte {}",
cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
));
}
moved
}
pub fn move_cursor_up(
&self,
cursor: TextCursor,
goal_x: &mut Option<f32>,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_up: from byte {} (affinity {:?})",
cursor.cluster_id.start_byte_in_run, cursor.affinity
));
}
let Some(current_item) = self.items.iter().find(|i| {
i.item
.as_cluster()
.is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
}) else {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_up: cursor not found in items, staying at byte {}",
cursor.cluster_id.start_byte_in_run
));
}
return cursor;
};
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_up: current line {}, position ({}, {})",
current_item.line_index, current_item.position.x, current_item.position.y
));
}
let target_line_idx = current_item.line_index.saturating_sub(1);
if current_item.line_index == target_line_idx {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_up: already at top line {}, staying put",
current_item.line_index
));
}
return cursor;
}
let current_x = goal_x.unwrap_or_else(|| {
let x = match cursor.affinity {
CursorAffinity::Leading => current_item.position.x,
CursorAffinity::Trailing => {
current_item.position.x + get_item_measure(¤t_item.item, false)
}
};
*goal_x = Some(x);
x
});
let target_y = self
.items
.iter()
.find(|i| i.line_index == target_line_idx)
.map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_up: target line {target_line_idx}, hittesting at ({current_x}, {target_y})"
));
}
let result = self
.hittest_cursor(LogicalPosition {
x: current_x,
y: target_y,
})
.unwrap_or(cursor);
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_up: result byte {} (affinity {:?})",
result.cluster_id.start_byte_in_run, result.affinity
));
}
result
}
pub fn move_cursor_down(
&self,
cursor: TextCursor,
goal_x: &mut Option<f32>,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_down: from byte {} (affinity {:?})",
cursor.cluster_id.start_byte_in_run, cursor.affinity
));
}
let Some(current_item) = self.items.iter().find(|i| {
i.item
.as_cluster()
.is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
}) else {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_down: cursor not found in items, staying at byte {}",
cursor.cluster_id.start_byte_in_run
));
}
return cursor;
};
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_down: current line {}, position ({}, {})",
current_item.line_index, current_item.position.x, current_item.position.y
));
}
let max_line = self.items.iter().map(|i| i.line_index).max().unwrap_or(0);
let target_line_idx = (current_item.line_index + 1).min(max_line);
if current_item.line_index == target_line_idx {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_down: already at bottom line {}, staying put",
current_item.line_index
));
}
return cursor;
}
let current_x = goal_x.unwrap_or_else(|| {
let x = match cursor.affinity {
CursorAffinity::Leading => current_item.position.x,
CursorAffinity::Trailing => {
current_item.position.x + get_item_measure(¤t_item.item, false)
}
};
*goal_x = Some(x);
x
});
let target_y = self
.items
.iter()
.find(|i| i.line_index == target_line_idx)
.map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_down: hit testing at ({current_x}, {target_y})"
));
}
let result = self
.hittest_cursor(LogicalPosition {
x: current_x,
y: target_y,
})
.unwrap_or(cursor);
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_down: result byte {}, affinity {:?}",
result.cluster_id.start_byte_in_run, result.affinity
));
}
result
}
pub fn move_cursor_to_line_start(
&self,
cursor: TextCursor,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_start: starting at byte {}, affinity {:?}",
cursor.cluster_id.start_byte_in_run, cursor.affinity
));
}
let Some(current_item) = self.items.iter().find(|i| {
i.item
.as_cluster()
.is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
}) else {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_start: cursor not found, staying at byte {}",
cursor.cluster_id.start_byte_in_run
));
}
return cursor;
};
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_start: current line {}, position ({}, {})",
current_item.line_index, current_item.position.x, current_item.position.y
));
}
let first_item_on_line = self
.items
.iter()
.filter(|i| i.line_index == current_item.line_index)
.min_by(|a, b| {
a.position
.x
.partial_cmp(&b.position.x)
.unwrap_or(Ordering::Equal)
});
if let Some(item) = first_item_on_line {
if let ShapedItem::Cluster(c) = &item.item {
let result = TextCursor {
cluster_id: c.source_cluster_id,
affinity: CursorAffinity::Leading,
};
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_start: result byte {}, affinity {:?}",
result.cluster_id.start_byte_in_run, result.affinity
));
}
return result;
}
}
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_start: no first item found, staying at byte {}",
cursor.cluster_id.start_byte_in_run
));
}
cursor
}
pub fn move_cursor_to_line_end(
&self,
cursor: TextCursor,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_end: starting at byte {}, affinity {:?}",
cursor.cluster_id.start_byte_in_run, cursor.affinity
));
}
let Some(current_item) = self.items.iter().find(|i| {
i.item
.as_cluster()
.is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
}) else {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_end: cursor not found, staying at byte {}",
cursor.cluster_id.start_byte_in_run
));
}
return cursor;
};
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_end: current line {}, position ({}, {})",
current_item.line_index, current_item.position.x, current_item.position.y
));
}
let last_item_on_line = self
.items
.iter()
.filter(|i| i.line_index == current_item.line_index)
.max_by(|a, b| {
a.position
.x
.partial_cmp(&b.position.x)
.unwrap_or(Ordering::Equal)
});
if let Some(item) = last_item_on_line {
if let ShapedItem::Cluster(c) = &item.item {
let result = TextCursor {
cluster_id: c.source_cluster_id,
affinity: CursorAffinity::Trailing,
};
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_end: result byte {}, affinity {:?}",
result.cluster_id.start_byte_in_run, result.affinity
));
}
return result;
}
}
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_line_end: no last item found, staying at byte {}",
cursor.cluster_id.start_byte_in_run
));
}
cursor
}
pub fn move_cursor_to_prev_word(
&self,
cursor: TextCursor,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_prev_word: starting at byte {}, affinity {:?}",
cursor.cluster_id.start_byte_in_run, cursor.affinity
));
}
let Some(current_pos) = self.items.iter().position(|i| {
i.item
.as_cluster()
.is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
}) else {
return cursor;
};
let mut pos = if cursor.affinity == CursorAffinity::Leading {
current_pos.checked_sub(1)
} else {
Some(current_pos)
};
while let Some(p) = pos {
if let Some(cluster) = self.items[p].item.as_cluster() {
if !cluster_is_word_boundary(cluster) {
break;
}
}
pos = p.checked_sub(1);
}
while let Some(p) = pos {
if let Some(cluster) = self.items[p].item.as_cluster() {
if cluster_is_word_boundary(cluster) {
if p + 1 < self.items.len() {
if let Some(c) = self.items[p + 1].item.as_cluster() {
return TextCursor {
cluster_id: c.source_cluster_id,
affinity: CursorAffinity::Leading,
};
}
}
break;
}
}
if p == 0 {
if let Some(c) = self.items[0].item.as_cluster() {
return TextCursor {
cluster_id: c.source_cluster_id,
affinity: CursorAffinity::Leading,
};
}
break;
}
pos = p.checked_sub(1);
}
if pos.is_none() {
if let Some(first) = self.get_first_cluster_cursor() {
return first;
}
}
cursor
}
pub fn move_cursor_to_next_word(
&self,
cursor: TextCursor,
debug: &mut Option<Vec<String>>,
) -> TextCursor {
if let Some(d) = debug {
d.push(format!(
"[Cursor] move_cursor_to_next_word: starting at byte {}, affinity {:?}",
cursor.cluster_id.start_byte_in_run, cursor.affinity
));
}
let Some(current_pos) = self.items.iter().position(|i| {
i.item
.as_cluster()
.is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
}) else {
return cursor;
};
let len = self.items.len();
let start = if cursor.affinity == CursorAffinity::Trailing {
current_pos + 1
} else {
current_pos
};
if start >= len {
return cursor;
}
let mut pos = start;
while pos < len {
if let Some(cluster) = self.items[pos].item.as_cluster() {
if cluster_is_word_boundary(cluster) {
break;
}
}
pos += 1;
}
while pos < len {
if let Some(cluster) = self.items[pos].item.as_cluster() {
if !cluster_is_word_boundary(cluster) {
return TextCursor {
cluster_id: cluster.source_cluster_id,
affinity: CursorAffinity::Leading,
};
}
}
pos += 1;
}
if let Some(last) = self.get_last_cluster_cursor() {
return last;
}
cursor
}
}
#[allow(clippy::match_same_arms)] fn get_baseline_for_item(item: &ShapedItem) -> Option<f32> {
match item {
ShapedItem::CombinedBlock {
baseline_offset, ..
} => Some(*baseline_offset),
ShapedItem::Object {
baseline_offset, ..
} => Some(*baseline_offset),
ShapedItem::Cluster(ref cluster) => {
cluster.glyphs.last().map(|last_glyph| last_glyph
.font_metrics
.baseline_scaled(last_glyph.style.font_size_px))
}
ShapedItem::Break { source, break_info } => {
None
}
ShapedItem::Tab { source, bounds } => {
None
}
}
}
#[derive(Debug, Clone, Default)]
pub struct OverflowInfo {
pub overflow_items: Vec<ShapedItem>,
pub unclipped_bounds: Rect,
}
impl OverflowInfo {
#[must_use] pub const fn has_overflow(&self) -> bool {
!self.overflow_items.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct UnifiedLine {
pub items: Vec<ShapedItem>,
pub cross_axis_position: f32,
pub constraints: LineConstraints,
pub is_last: bool,
}
pub type CacheId = u64;
#[derive(Debug, Clone)]
pub struct LayoutFragment {
pub id: String,
pub constraints: UnifiedConstraints,
}
#[derive(Debug, Clone)]
pub(crate) struct FlowLayout {
pub(crate) fragment_layouts: HashMap<String, Arc<UnifiedLayout>>,
pub(crate) remaining_items: Vec<ShapedItem>,
}
#[derive(Copy, Debug, Clone, Default)]
pub struct IntrinsicTextSizes {
pub min_content_width: f32,
pub max_content_width: f32,
pub max_content_height: f32,
}
#[derive(Clone, Debug)]
pub struct CachedLineBreaks {
pub line_ranges: Vec<(usize, usize)>,
pub line_widths: Vec<f32>,
pub available_width: f32,
}
#[derive(Copy, Clone, Debug)]
pub enum IncrementalRelayoutResult {
GlyphSwap,
LineShift {
affected_item: usize,
delta: f32,
},
PartialReflow {
reflow_from_line: usize,
},
FullRelayout,
}
#[must_use] pub fn extract_line_breaks(
items: &[PositionedItem],
available_width: f32,
) -> CachedLineBreaks {
let mut line_ranges = Vec::new();
let mut line_widths = Vec::new();
if items.is_empty() {
return CachedLineBreaks { line_ranges, line_widths, available_width };
}
let mut line_start = 0usize;
let mut current_line = items[0].line_index;
let mut line_width = 0.0f32;
for (i, item) in items.iter().enumerate() {
if item.line_index != current_line {
line_ranges.push((line_start, i));
line_widths.push(line_width);
line_start = i;
current_line = item.line_index;
line_width = 0.0;
}
line_width += get_item_measure(&item.item, false);
}
line_ranges.push((line_start, items.len()));
line_widths.push(line_width);
CachedLineBreaks { line_ranges, line_widths, available_width }
}
#[must_use] pub fn try_incremental_relayout(
dirty_item_indices: &[usize],
old_advances: &[f32],
new_advances: &[f32],
line_breaks: &CachedLineBreaks,
) -> IncrementalRelayoutResult {
if dirty_item_indices.is_empty() {
return IncrementalRelayoutResult::GlyphSwap;
}
for &dirty_idx in dirty_item_indices {
if dirty_idx >= old_advances.len() || dirty_idx >= new_advances.len() {
return IncrementalRelayoutResult::FullRelayout;
}
let old_adv = old_advances[dirty_idx];
let new_adv = new_advances[dirty_idx];
let delta = new_adv - old_adv;
if delta.abs() < 0.001 {
continue;
}
let line_idx = line_breaks.line_ranges.iter()
.position(|&(start, end)| dirty_idx >= start && dirty_idx < end);
let Some(line_idx) = line_idx else {
return IncrementalRelayoutResult::FullRelayout;
};
let old_line_width = line_breaks.line_widths[line_idx];
let new_line_width = old_line_width + delta;
if new_line_width <= line_breaks.available_width {
return IncrementalRelayoutResult::LineShift {
affected_item: dirty_idx,
delta,
};
}
return IncrementalRelayoutResult::PartialReflow {
reflow_from_line: line_idx,
};
}
IncrementalRelayoutResult::GlyphSwap
}
#[derive(Debug)]
pub(crate) struct PerItemShapedEntry {
pub(crate) clusters: Vec<ShapedItem>,
pub(crate) total_advance: f32,
}
#[derive(Debug)]
pub struct TextShapingCache {
logical_items: HashMap<CacheId, Arc<Vec<LogicalItem>>>,
visual_items: HashMap<CacheId, Arc<Vec<VisualItem>>>,
shaped_items: HashMap<CacheId, Arc<Vec<ShapedItem>>>,
per_item_shaped: HashMap<u64, Arc<PerItemShapedEntry>>,
per_item_accessed: HashSet<u64>,
generation: u64,
}
#[derive(Copy, Debug, Clone, Default)]
pub struct TextCacheMemoryReport {
pub logical_items_entries: usize,
pub logical_items_bytes: usize,
pub visual_items_entries: usize,
pub visual_items_bytes: usize,
pub shaped_items_entries: usize,
pub shaped_items_bytes: usize,
pub shaped_glyph_bytes: usize,
pub shaped_cluster_text_bytes: usize,
pub per_item_shaped_entries: usize,
pub per_item_shaped_bytes: usize,
}
impl TextCacheMemoryReport {
#[must_use] pub const fn total_bytes(&self) -> usize {
self.logical_items_bytes
+ self.visual_items_bytes
+ self.shaped_items_bytes
+ self.shaped_glyph_bytes
+ self.shaped_cluster_text_bytes
+ self.per_item_shaped_bytes
}
}
impl TextShapingCache {
#[must_use] pub fn new() -> Self {
Self {
logical_items: HashMap::new(),
visual_items: HashMap::new(),
shaped_items: HashMap::new(),
per_item_shaped: HashMap::new(),
per_item_accessed: HashSet::new(),
generation: 0,
}
}
#[allow(clippy::field_reassign_with_default)] #[must_use] pub fn memory_report(&self) -> TextCacheMemoryReport {
let mut r = TextCacheMemoryReport::default();
r.logical_items_entries = self.logical_items.len();
for arc in self.logical_items.values() {
r.logical_items_bytes += arc.capacity() * size_of::<LogicalItem>();
}
r.visual_items_entries = self.visual_items.len();
for arc in self.visual_items.values() {
r.visual_items_bytes += arc.capacity() * size_of::<VisualItem>();
}
r.shaped_items_entries = self.shaped_items.len();
for arc in self.shaped_items.values() {
r.shaped_items_bytes += arc.capacity() * size_of::<ShapedItem>();
for item in arc.iter() {
if let ShapedItem::Cluster(c) = item {
r.shaped_glyph_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
r.shaped_cluster_text_bytes += c.text.capacity();
}
}
}
r.per_item_shaped_entries = self.per_item_shaped.len();
for arc in self.per_item_shaped.values() {
r.per_item_shaped_bytes += arc.clusters.capacity() * size_of::<ShapedItem>();
for item in &arc.clusters {
if let ShapedItem::Cluster(c) = item {
r.per_item_shaped_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
r.per_item_shaped_bytes += c.text.capacity();
}
}
}
r
}
pub fn begin_generation(&mut self) {
if self.generation > 0 && !self.per_item_accessed.is_empty() {
let accessed = &self.per_item_accessed;
self.per_item_shaped.retain(|k, _| accessed.contains(k));
}
self.per_item_accessed.clear();
self.generation += 1;
}
#[must_use] pub fn use_old_layout(
old_constraints: &UnifiedConstraints,
new_constraints: &UnifiedConstraints,
old_content: &[InlineContent],
new_content: &[InlineContent],
) -> bool {
if old_constraints != new_constraints {
return false;
}
if old_content.len() != new_content.len() {
return false;
}
for (old, new) in old_content.iter().zip(new_content.iter()) {
if !Self::inline_content_layout_eq(old, new) {
return false;
}
}
true
}
fn inline_content_layout_eq(old: &InlineContent, new: &InlineContent) -> bool {
use InlineContent::{Text, Image, Space, LineBreak, Tab, Marker, Shape, Ruby};
match (old, new) {
(Text(old_run), Text(new_run)) => {
old_run.text == new_run.text
&& old_run.style.layout_eq(&new_run.style)
}
(Image(old_img), Image(new_img)) => {
old_img.intrinsic_size == new_img.intrinsic_size
&& old_img.display_size == new_img.display_size
&& old_img.baseline_offset == new_img.baseline_offset
&& old_img.alignment == new_img.alignment
}
(Space(old_sp), Space(new_sp)) => old_sp == new_sp,
(LineBreak(old_br), LineBreak(new_br)) => old_br == new_br,
(Tab { style: old_style }, Tab { style: new_style }) => old_style.layout_eq(new_style),
(Marker { run: old_run, position_outside: old_pos },
Marker { run: new_run, position_outside: new_pos }) => {
old_pos == new_pos
&& old_run.text == new_run.text
&& old_run.style.layout_eq(&new_run.style)
}
(Shape(old_shape), Shape(new_shape)) => {
old_shape.shape_def == new_shape.shape_def
&& old_shape.baseline_offset == new_shape.baseline_offset
}
(Ruby { base: old_base, text: old_text, style: old_style },
Ruby { base: new_base, text: new_text, style: new_style }) => {
old_style.layout_eq(new_style)
&& old_base.len() == new_base.len()
&& old_text.len() == new_text.len()
&& old_base.iter().zip(new_base.iter())
.all(|(o, n)| Self::inline_content_layout_eq(o, n))
&& old_text.iter().zip(new_text.iter())
.all(|(o, n)| Self::inline_content_layout_eq(o, n))
}
_ => false,
}
}
}
impl Default for TextShapingCache {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct LogicalItemsKey<'a> {
pub(crate) inline_content_hash: u64,
pub(crate) default_font_size: u32,
pub(crate) _marker: std::marker::PhantomData<&'a ()>,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct VisualItemsKey {
pub(crate) logical_items_id: CacheId,
pub(crate) base_direction: BidiDirection,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct ShapedItemsKey {
pub(crate) visual_items_id: CacheId,
pub(crate) style_hash: u64,
}
impl ShapedItemsKey {
pub(crate) fn new(visual_items_id: CacheId, visual_items: &[VisualItem]) -> Self {
let style_hash = {
let mut hasher = DefaultHasher::new();
for item in visual_items {
match &item.logical_source {
LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
style.as_ref().hash(&mut hasher);
}
_ => {}
}
}
hasher.finish()
};
Self {
visual_items_id,
style_hash,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct LayoutKey {
pub(crate) shaped_items_id: CacheId,
pub(crate) constraints: UnifiedConstraints,
}
fn calculate_id<T: Hash>(item: &T) -> CacheId {
let mut hasher = DefaultHasher::new();
item.hash(&mut hasher);
hasher.finish()
}
impl TextShapingCache {
#[allow(clippy::too_many_lines)] pub fn layout_flow<T: ParsedFontTrait>(
&mut self,
content: &[InlineContent],
style_overrides: &[StyleOverride],
flow_chain: &[LayoutFragment],
font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
fc_cache: &FcFontCache,
loaded_fonts: &LoadedFonts<T>,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Result<FlowLayout, LayoutError> {
#[cfg(feature = "web_lift")]
unsafe {
crate::az_mark((0x60BD0) as u32, (content.as_ptr() as usize as u32) as u32);
crate::az_mark((0x60BD4) as u32, (content.len() as u32 | 0xC0DE0000) as u32);
}
const PER_ITEM_CACHE_MAX: usize = 4096;
if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
self.begin_generation();
}
let logical_items_id = calculate_id(&content);
let logical_items = self
.logical_items
.entry(logical_items_id)
.or_insert_with(|| {
Arc::new(create_logical_items(content, style_overrides, debug_messages))
})
.clone();
let default_constraints = UnifiedConstraints::default();
let first_constraints = flow_chain
.first()
.map_or(&default_constraints, |f| &f.constraints);
let unicode_bidi_val = first_constraints.unicode_bidi;
let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
let has_strong = logical_items.iter().any(|item| {
if let LogicalItem::Text { text, .. } = item {
matches!(unicode_bidi::get_base_direction(text.as_str()),
unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
} else {
false
}
});
if has_strong {
get_base_direction_from_logical(&logical_items)
} else {
first_constraints.direction.unwrap_or(BidiDirection::Ltr)
}
} else {
first_constraints.direction.unwrap_or(BidiDirection::Ltr)
};
let visual_key = VisualItemsKey {
logical_items_id,
base_direction,
};
let visual_items_id = calculate_id(&visual_key);
let visual_items = self
.visual_items
.entry(visual_items_id)
.or_insert_with(|| {
Arc::new(
reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
)
})
.clone();
let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
let shaped_items_id = calculate_id(&shaped_key);
let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) {
cached.clone()
} else {
let items = Arc::new(shape_visual_items_with_per_item_cache(
&visual_items,
&mut self.per_item_shaped,
&mut self.per_item_accessed,
font_chain_cache,
fc_cache,
loaded_fonts,
debug_messages,
)?);
self.shaped_items.insert(shaped_items_id, items.clone());
items
};
let oriented_items = apply_text_orientation(shaped_items, first_constraints);
let mut fragment_layouts = HashMap::new();
let mut cursor = BreakCursor::with_word_break(&oriented_items, first_constraints.word_break);
cursor.hyphens = first_constraints.hyphenation;
cursor.line_break = first_constraints.line_break;
#[allow(clippy::no_effect_underscore_binding)] let mut _az_flow_iters: usize = 0;
for fragment in flow_chain {
#[cfg(feature = "web_lift")]
{
_az_flow_iters += 1;
unsafe { crate::az_mark((0x60BC0) as u32, (_az_flow_iters as u32 | 0xC0DE0000) as u32); }
if _az_flow_iters > 256 {
break;
}
}
let fragment_layout = perform_fragment_layout(
&mut cursor,
&logical_items,
&fragment.constraints,
debug_messages,
loaded_fonts,
)?;
fragment_layouts.insert(fragment.id.clone(), Arc::new(fragment_layout));
if cursor.is_done() {
break; }
}
Ok(FlowLayout {
fragment_layouts,
remaining_items: cursor.drain_remaining(),
})
}
#[allow(clippy::too_many_lines)] pub fn measure_intrinsic_widths<T: ParsedFontTrait>(
&mut self,
content: &[InlineContent],
style_overrides: &[StyleOverride],
constraints: &UnifiedConstraints,
font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
fc_cache: &FcFontCache,
loaded_fonts: &LoadedFonts<T>,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Result<IntrinsicTextSizes, LayoutError> {
const PER_ITEM_CACHE_MAX: usize = 4096;
if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
self.begin_generation();
}
let logical_items_id = calculate_id(&content);
let logical_items = self
.logical_items
.entry(logical_items_id)
.or_insert_with(|| {
Arc::new(create_logical_items(content, style_overrides, debug_messages))
})
.clone();
let unicode_bidi_val = constraints.unicode_bidi;
let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
let has_strong = logical_items.iter().any(|item| {
if let LogicalItem::Text { text, .. } = item {
matches!(unicode_bidi::get_base_direction(text.as_str()),
unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
} else {
false
}
});
if has_strong {
get_base_direction_from_logical(&logical_items)
} else {
constraints.direction.unwrap_or(BidiDirection::Ltr)
}
} else {
constraints.direction.unwrap_or(BidiDirection::Ltr)
};
let visual_key = VisualItemsKey {
logical_items_id,
base_direction,
};
let visual_items_id = calculate_id(&visual_key);
let visual_items = self
.visual_items
.entry(visual_items_id)
.or_insert_with(|| {
Arc::new(
reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
)
})
.clone();
let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
let shaped_items_id = calculate_id(&shaped_key);
let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) { cached.clone() } else {
let items = Arc::new(shape_visual_items_with_per_item_cache(
&visual_items,
&mut self.per_item_shaped,
&mut self.per_item_accessed,
font_chain_cache,
fc_cache,
loaded_fonts,
debug_messages,
)?);
self.shaped_items.insert(shaped_items_id, items.clone());
items
};
let oriented_items = apply_text_orientation(shaped_items, constraints);
let word_break = constraints.word_break;
let hyphens = constraints.hyphenation;
let mut total = 0.0f32; let mut max_line = 0.0f32; let mut max_word = 0.0f32;
let mut cur_word = 0.0f32;
let mut max_line_height = 0.0f32;
for item in oriented_items.iter() {
if let ShapedItem::Break { .. } = item {
if total > max_line { max_line = total; }
if cur_word > max_word { max_word = cur_word; }
total = 0.0;
cur_word = 0.0;
continue;
}
let advance = match item {
ShapedItem::Cluster(c) => {
let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
let mut a = c.advance + total_kerning;
if !is_cursive_script_cluster(c) {
a += c.style.letter_spacing.resolve_px(c.style.font_size_px);
}
if is_word_separator(item) {
a += c.style.word_spacing.resolve_px(c.style.font_size_px);
}
a
}
ShapedItem::CombinedBlock { bounds, .. }
| ShapedItem::Object { bounds, .. }
| ShapedItem::Tab { bounds, .. } => bounds.width,
ShapedItem::Break { .. } => 0.0,
};
let adv = advance.max(0.0);
total += adv;
let (asc, desc) = get_item_vertical_metrics_approx(item);
let h = (asc + desc).max(item.bounds().height);
if h > max_line_height {
max_line_height = h;
}
if is_break_opportunity_with_word_break(item, word_break, hyphens) {
if cur_word > max_word {
max_word = cur_word;
}
if !is_word_separator(item) && adv > max_word {
max_word = adv;
}
cur_word = 0.0;
} else {
cur_word += adv;
}
}
if cur_word > max_word {
max_word = cur_word;
}
if total > max_line {
max_line = total;
}
let min_content_width = if matches!(constraints.white_space_mode, WhiteSpaceMode::Nowrap) {
max_line
} else {
max_word
};
Ok(IntrinsicTextSizes {
min_content_width,
max_content_width: max_line,
max_content_height: max_line_height,
})
}
}
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] pub fn create_logical_items(
content: &[InlineContent],
style_overrides: &[StyleOverride],
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Vec<LogicalItem> {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"\n--- Entering create_logical_items (Refactored) ---".to_string(),
));
msgs.push(LayoutDebugMessage::info(format!(
"Input content length: {}",
content.len()
)));
msgs.push(LayoutDebugMessage::info(format!(
"Input overrides length: {}",
style_overrides.len()
)));
}
let mut items: Vec<LogicalItem> = Vec::new();
let mut style_cache: HashMap<u64, Arc<StyleProperties>> = HashMap::new();
let mut run_overrides: HashMap<u32, HashMap<u32, &PartialStyleProperties>> = HashMap::new();
for override_item in style_overrides {
run_overrides
.entry(override_item.target.run_index)
.or_default()
.insert(override_item.target.item_index, &override_item.style);
}
for (run_idx, inline_item) in content.iter().enumerate() {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Processing content run #{run_idx}"
)));
}
let marker_position_outside = match inline_item {
InlineContent::Marker {
position_outside, ..
} => Some(*position_outside),
_ => None,
};
if let InlineContent::Text(run) | InlineContent::Marker { run, .. } = inline_item {
let text = &run.text;
if text.is_empty() {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
" Run is empty, skipping.".to_string(),
));
}
continue;
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(" Run text: '{text}'")));
}
let current_run_overrides = run_overrides.get(&(run_idx as u32));
let mut boundaries = BTreeSet::new();
boundaries.insert(0);
boundaries.insert(text.len());
let needs_scan = current_run_overrides.is_some()
|| run.style.text_combine_upright.is_some();
let mut scan_cursor = 0;
while needs_scan && scan_cursor < text.len() {
let style_at_cursor = current_run_overrides.and_then(|o| o.get(&(scan_cursor as u32))).map_or_else(|| (*run.style).clone(), |partial| run.style.apply_override(partial));
let current_char = text[scan_cursor..].chars().next().unwrap();
if let Some(TextCombineUpright::Digits(max_digits)) =
style_at_cursor.text_combine_upright
{
if max_digits > 0 && current_char.is_ascii_digit() {
let digit_chunk: String = text[scan_cursor..]
.chars()
.take(max_digits as usize)
.take_while(char::is_ascii_digit)
.collect();
let end_of_chunk = scan_cursor + digit_chunk.len();
boundaries.insert(scan_cursor);
boundaries.insert(end_of_chunk);
scan_cursor = end_of_chunk; continue;
}
}
if current_run_overrides
.and_then(|o| o.get(&(scan_cursor as u32)))
.is_some()
{
let grapheme_len = text[scan_cursor..]
.graphemes(true)
.next()
.unwrap_or("")
.len();
boundaries.insert(scan_cursor);
boundaries.insert(scan_cursor + grapheme_len);
scan_cursor += grapheme_len;
continue;
}
scan_cursor += current_char.len_utf8();
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" Boundaries: {boundaries:?}"
)));
}
for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
let (start, end) = (*start, *end);
if start >= end {
continue;
}
let text_slice = &text[start..end];
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" Processing chunk from {start} to {end}: '{text_slice}'"
)));
}
let style_to_use = current_run_overrides.and_then(|o| o.get(&(start as u32))).map_or_else(|| run.style.clone(), |partial_style| {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" -> Applying override at byte {start}"
)));
}
let mut hasher = DefaultHasher::new();
Arc::as_ptr(&run.style).hash(&mut hasher);
partial_style.hash(&mut hasher);
style_cache
.entry(hasher.finish())
.or_insert_with(|| Arc::new(run.style.apply_override(partial_style)))
.clone()
});
let is_combinable_chunk = match &style_to_use.text_combine_upright {
Some(TextCombineUpright::All) => !text_slice.is_empty(),
Some(TextCombineUpright::Digits(max_digits)) => {
*max_digits > 0
&& !text_slice.is_empty()
&& text_slice.chars().all(|c| c.is_ascii_digit())
&& text_slice.chars().count() <= *max_digits as usize
}
_ => false,
};
if is_combinable_chunk {
let trimmed = text_slice.trim();
let combined_text = if trimmed.is_empty() {
text_slice.to_string()
} else {
trimmed.to_string()
};
items.push(LogicalItem::CombinedText {
source: ContentIndex {
run_index: run_idx as u32,
item_index: start as u32,
},
text: combined_text,
style: style_to_use,
});
} else {
items.push(LogicalItem::Text {
source: ContentIndex {
run_index: run_idx as u32,
item_index: start as u32,
},
text: text_slice.to_string(),
style: style_to_use,
marker_position_outside,
source_node_id: run.source_node_id,
});
}
}
} else {
match inline_item {
InlineContent::LineBreak(break_info) => {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" LineBreak: {break_info:?}"
)));
}
items.push(LogicalItem::Break {
source: ContentIndex {
run_index: run_idx as u32,
item_index: 0,
},
break_info: *break_info,
});
}
InlineContent::Tab { style } => {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(" Tab character".to_string()));
}
items.push(LogicalItem::Tab {
source: ContentIndex {
run_index: run_idx as u32,
item_index: 0,
},
style: style.clone(),
});
}
_ => {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
" Run is not text, creating generic LogicalItem.".to_string(),
));
}
items.push(LogicalItem::Object {
source: ContentIndex {
run_index: run_idx as u32,
item_index: 0,
},
content: inline_item.clone(),
});
}
}
}
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"--- Exiting create_logical_items, created {} items ---",
items.len()
)));
}
items
}
#[must_use] pub fn get_base_direction_from_logical(logical_items: &[LogicalItem]) -> BidiDirection {
let first_strong = logical_items.iter().find_map(|item| {
if let LogicalItem::Text { text, .. } = item {
Some(unicode_bidi::get_base_direction(text.as_str()))
} else {
None
}
});
match first_strong {
Some(unicode_bidi::Direction::Rtl) => BidiDirection::Rtl,
_ => BidiDirection::Ltr,
}
}
#[allow(clippy::match_same_arms)] #[allow(clippy::too_many_lines)] pub fn reorder_logical_items(
logical_items: &[LogicalItem],
base_direction: BidiDirection,
unicode_bidi: UnicodeBidi,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Result<Vec<VisualItem>, LayoutError> {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"\n--- Entering reorder_logical_items ---".to_string(),
));
msgs.push(LayoutDebugMessage::info(format!(
"Input logical items count: {}",
logical_items.len()
)));
msgs.push(LayoutDebugMessage::info(format!(
"Base direction: {base_direction:?}"
)));
}
let mut bidi_str = String::new();
let mut item_map = Vec::new();
let mut logical_item_starts = Vec::with_capacity(logical_items.len());
for (idx, item) in logical_items.iter().enumerate() {
let text = match item {
LogicalItem::Text { text, .. } => text.as_str(),
LogicalItem::CombinedText { text, .. } => text.as_str(),
_ => "\u{FFFC}",
};
let start_byte = bidi_str.len();
logical_item_starts.push(start_byte);
bidi_str.push_str(text);
for _ in start_byte..bidi_str.len() {
item_map.push(idx);
}
}
if bidi_str.is_empty() {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"Bidi string is empty, returning.".to_string(),
));
}
return Ok(Vec::new());
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Constructed bidi string: '{bidi_str}'"
)));
}
let bidi_level = if unicode_bidi == UnicodeBidi::Plaintext {
None
} else if base_direction == BidiDirection::Rtl {
Some(Level::rtl())
} else {
Some(Level::ltr())
};
let bidi_info = BidiInfo::new(&bidi_str, bidi_level);
let para = &bidi_info.paragraphs[0];
let (levels, visual_runs) = bidi_info.visual_runs(para, para.range.clone());
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"Bidi visual runs generated:".to_string(),
));
for (i, run_range) in visual_runs.iter().enumerate() {
let level = levels[run_range.start].number();
let slice = &bidi_str[run_range.start..run_range.end];
msgs.push(LayoutDebugMessage::info(format!(
" Run {i}: range={run_range:?}, level={level}, text='{slice}'"
)));
}
}
let mut visual_items = Vec::new();
for run_range in visual_runs {
let bidi_level = BidiLevel::new(levels[run_range.start].number());
let mut sub_run_start = run_range.start;
for i in (run_range.start + 1)..run_range.end {
if item_map[i] != item_map[sub_run_start] {
let logical_idx = item_map[sub_run_start];
let logical_item = &logical_items[logical_idx];
let text_slice = &bidi_str[sub_run_start..i];
visual_items.push(VisualItem {
logical_source: logical_item.clone(),
bidi_level,
script: crate::text3::script::detect_script(text_slice)
.unwrap_or(Script::Latin),
text: text_slice.to_string(),
run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
});
sub_run_start = i;
}
}
let logical_idx = item_map[sub_run_start];
let logical_item = &logical_items[logical_idx];
let text_slice = &bidi_str[sub_run_start..run_range.end];
visual_items.push(VisualItem {
logical_source: logical_item.clone(),
bidi_level,
script: crate::text3::script::detect_script(text_slice).unwrap_or(Script::Latin),
text: text_slice.to_string(),
run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
});
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"Final visual items produced:".to_string(),
));
for (i, item) in visual_items.iter().enumerate() {
msgs.push(LayoutDebugMessage::info(format!(
" Item {}: level={}, text='{}'",
i,
item.bidi_level.level(),
item.text
)));
}
msgs.push(LayoutDebugMessage::info(
"--- Exiting reorder_logical_items ---".to_string(),
));
}
Ok(visual_items)
}
#[allow(clippy::implicit_hasher)] pub fn shape_visual_items_with_per_item_cache<T: ParsedFontTrait>(
visual_items: &[VisualItem],
per_item_cache: &mut HashMap<u64, Arc<PerItemShapedEntry>>,
per_item_accessed: &mut HashSet<u64>,
font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
fc_cache: &FcFontCache,
loaded_fonts: &LoadedFonts<T>,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Result<Vec<ShapedItem>, LayoutError> {
use std::hash::{Hash, Hasher};
let mut shaped = Vec::new();
let mut idx = 0;
while idx < visual_items.len() {
let item = &visual_items[idx];
let (layout_hash, bidi_level, script) = match &item.logical_source {
LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
(style.layout_hash(), item.bidi_level, item.script)
}
_ => {
let single = shape_visual_items(
&visual_items[idx..=idx],
font_chain_cache, fc_cache, loaded_fonts, debug_messages,
)?;
shaped.extend(single);
idx += 1;
continue;
}
};
let mut coalesce_end = idx + 1;
while coalesce_end < visual_items.len() {
let next = &visual_items[coalesce_end];
let next_layout_hash = match &next.logical_source {
LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
Some(style.layout_hash())
}
_ => None,
};
if let Some(nlh) = next_layout_hash {
if nlh == layout_hash
&& next.bidi_level == bidi_level
&& next.script == script
{
coalesce_end += 1;
} else {
break;
}
} else {
break;
}
}
let mut hasher = DefaultHasher::new();
for item in &visual_items[idx..coalesce_end] {
item.text.hash(&mut hasher);
}
layout_hash.hash(&mut hasher);
bidi_level.hash(&mut hasher);
(script as u32).hash(&mut hasher);
let group_key = hasher.finish();
per_item_accessed.insert(group_key);
if let Some(cached) = per_item_cache.get(&group_key) {
shaped.extend(cached.clusters.iter().cloned());
} else {
let group_items = shape_visual_items(
&visual_items[idx..coalesce_end],
font_chain_cache, fc_cache, loaded_fonts, debug_messages,
)?;
let total_advance: f32 = group_items.iter().map(|item| {
match item {
ShapedItem::Cluster(c) => c.advance,
_ => 0.0,
}
}).sum();
per_item_cache.insert(group_key, Arc::new(PerItemShapedEntry {
clusters: group_items.clone(),
total_advance,
}));
shaped.extend(group_items);
}
idx = coalesce_end;
}
Ok(shaped)
}
fn split_text_by_font_coverage<T: ParsedFontTrait>(
text: &str,
font_chain: &rust_fontconfig::FontFallbackChain,
fc_cache: &FcFontCache,
loaded_fonts: &LoadedFonts<T>,
) -> Vec<(usize, usize, FontId)> {
let mut segments: Vec<(usize, usize, FontId)> = Vec::new();
let notdef_font_id = loaded_fonts.iter().map(|(id, _)| *id).min();
for (byte_idx, ch) in text.char_indices() {
let char_end = byte_idx + ch.len_utf8();
let font_id = font_chain
.resolve_char(fc_cache, ch)
.map(|(id, _)| id)
.or_else(|| {
loaded_fonts
.iter()
.filter(|(_, font)| font.has_glyph(ch as u32))
.map(|(id, _)| *id)
.min()
})
.or(notdef_font_id);
if let Some(font_id) = font_id {
match segments.last_mut() {
Some(last) if last.2 == font_id && last.1 == byte_idx => {
last.1 = char_end;
}
_ => {
segments.push((byte_idx, char_end, font_id));
}
}
}
}
segments
}
fn measure_run_advance<T: ParsedFontTrait>(
text: &str,
style: &Arc<StyleProperties>,
script: Script,
source: ContentIndex,
font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
fc_cache: &FcFontCache,
loaded_fonts: &LoadedFonts<T>,
) -> Option<f32> {
if text.is_empty() {
return Some(0.0);
}
let language = script_to_language(script, text);
match &style.font_stack {
FontStack::Ref(font_ref) => {
let glyphs = font_ref
.shape_text(text, script, language, BidiDirection::Ltr, style.as_ref())
.ok()?;
Some(glyphs.iter().map(|g| g.advance + g.kerning).sum())
}
FontStack::Stack(selectors) => {
let cache_key = FontChainKey::from_selectors(selectors);
let font_chain = font_chain_cache.get(&cache_key)?;
let clusters = shape_with_font_fallback(
text, script, language, BidiDirection::Ltr, style, source, None, font_chain,
fc_cache, loaded_fonts,
)
.ok()?;
Some(clusters.iter().map(|c| c.advance).sum())
}
}
}
#[allow(clippy::cast_possible_truncation)] fn shape_with_font_fallback<T: ParsedFontTrait>(
text: &str,
script: Script,
language: Language,
direction: BidiDirection,
style: &Arc<StyleProperties>,
source_index: ContentIndex,
source_node_id: Option<NodeId>,
font_chain: &rust_fontconfig::FontFallbackChain,
fc_cache: &FcFontCache,
loaded_fonts: &LoadedFonts<T>,
) -> Result<Vec<ShapedCluster>, LayoutError> {
static FONT_FB_DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let dbg = *FONT_FB_DEBUG.get_or_init(|| {
std::env::var_os("AZ_FONT_FALLBACK_DEBUG").is_some()
});
let segments = split_text_by_font_coverage(text, font_chain, fc_cache, loaded_fonts);
if dbg && segments.len() > 1 {
eprintln!(
"[FONT FALLBACK] text needs {} font segments for '{}' ({}..{} bytes)",
segments.len(),
text.chars().take(40).collect::<String>(),
0, text.len()
);
}
unsafe { crate::az_mark(0x60850_u32, segments.len() as u32); } if segments.len() <= 1 {
let (seg_start, seg_end, font_id) = if let Some(s) = segments.first() { unsafe { crate::az_mark(0x60854_u32, 0x0000_0001_u32); } s } else {
unsafe { crate::az_mark(0x60854_u32, 0x0000_00EE_u32); } if dbg {
eprintln!("[FONT FALLBACK] no font could render any char in '{}'", text.chars().take(20).collect::<String>());
}
return Ok(Vec::new());
};
let font = if let Some(f) = loaded_fonts.get(font_id) { unsafe { crate::az_mark(0x60858_u32, 0x0000_0001_u32); } f } else {
unsafe { crate::az_mark(0x60858_u32, 0x0000_00EE_u32); } if dbg {
eprintln!("[FONT FALLBACK] font {:?} not in loaded_fonts for '{}'", font_id, text.chars().take(20).collect::<String>());
}
return Ok(Vec::new());
};
if *seg_start == 0 && *seg_end == text.len() {
unsafe { crate::az_mark(0x60860_u32, 0xC0DE_0860_u32); } return shape_text_correctly(
text, script, language, direction,
font, style, source_index, source_node_id,
);
}
let mut clusters = shape_text_correctly(
&text[*seg_start..*seg_end], script, language, direction,
font, style, source_index, source_node_id,
)?;
if *seg_start > 0 {
for cluster in &mut clusters {
cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
}
}
return Ok(clusters);
}
let mut all_clusters = Vec::new();
for (seg_start, seg_end, font_id) in &segments {
let Some(font) = loaded_fonts.get(font_id) else {
if dbg {
eprintln!("[FONT FALLBACK] font {font_id:?} NOT loaded, skipping segment bytes {seg_start}..{seg_end}");
}
continue;
};
let segment_text = &text[*seg_start..*seg_end];
if dbg {
eprintln!(
"[FONT FALLBACK] text='{segment_text}' uses font {font_id:?} (bytes {seg_start}..{seg_end})"
);
}
let mut seg_clusters = shape_text_correctly(
segment_text, script, language, direction,
font, style, source_index, source_node_id,
)?;
if *seg_start > 0 {
for cluster in &mut seg_clusters {
cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
}
}
all_clusters.extend(seg_clusters);
}
Ok(all_clusters)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] #[allow(clippy::implicit_hasher)] #[allow(clippy::match_same_arms)] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] pub fn shape_visual_items<T: ParsedFontTrait>(
visual_items: &[VisualItem],
font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
fc_cache: &FcFontCache,
loaded_fonts: &LoadedFonts<T>,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Result<Vec<ShapedItem>, LayoutError> {
let mut shaped = Vec::new();
let mut idx = 0;
let mut _coalesced_runs = 0usize;
let mut _total_runs = 0usize;
let mut _shape_calls = 0usize;
while idx < visual_items.len() {
let item = &visual_items[idx];
match &item.logical_source {
LogicalItem::Text {
style,
source,
marker_position_outside,
source_node_id,
..
} => {
let layout_hash = style.layout_hash();
let bidi_level = item.bidi_level;
let script = item.script;
let mut coalesce_end = idx + 1;
while coalesce_end < visual_items.len() {
let next = &visual_items[coalesce_end];
if let LogicalItem::Text { style: next_style, .. } = &next.logical_source {
if next_style.layout_hash() == layout_hash
&& next.bidi_level == bidi_level
&& next.script == script
{
coalesce_end += 1;
} else {
break;
}
} else {
break;
}
}
let coalesce_count = coalesce_end - idx;
if coalesce_count > 1 {
_coalesced_runs += coalesce_count;
_shape_calls += 1;
let total_text_len: usize = visual_items[idx..coalesce_end]
.iter()
.map(|v| v.text.len())
.sum();
let mut merged_text = String::with_capacity(total_text_len);
let mut byte_ranges: Vec<(
usize, usize,
Arc<StyleProperties>,
ContentIndex,
Option<NodeId>,
Option<bool>,
usize,
)> = Vec::with_capacity(coalesce_count);
for item in &visual_items[idx..coalesce_end] {
let start = merged_text.len();
merged_text.push_str(&item.text);
let end = merged_text.len();
if let LogicalItem::Text {
style: s, source: src, source_node_id: nid,
marker_position_outside: mpo, ..
} = &item.logical_source {
byte_ranges.push((start, end, s.clone(), *src, *nid, *mpo, item.run_byte_offset));
}
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[TextLayout] Coalescing {} text runs ({} bytes) into single shaping call",
coalesce_count, merged_text.len()
)));
}
let direction = if bidi_level.is_rtl() {
BidiDirection::Rtl
} else {
BidiDirection::Ltr
};
let language = script_to_language(script, &merged_text);
let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
FontStack::Ref(font_ref) => {
shape_text_correctly(
&merged_text, script, language, direction,
font_ref, style, *source, *source_node_id,
)
}
FontStack::Stack(selectors) => {
let cache_key = FontChainKey::from_selectors(selectors);
let Some(font_chain) = font_chain_cache.get(&cache_key) else { idx = coalesce_end; continue; };
shape_with_font_fallback(
&merged_text, script, language, direction,
style, *source, *source_node_id,
font_chain, fc_cache, loaded_fonts,
)
}
};
let shaped_clusters = shaped_clusters_result?;
for cluster in shaped_clusters {
let byte_pos = cluster.source_cluster_id.start_byte_in_run as usize;
let orig = byte_ranges.iter().find(|(start, end, ..)| {
byte_pos >= *start && byte_pos < *end
});
let mut cluster = cluster;
if let Some((range_start, _, orig_style, orig_source, orig_nid, orig_mpo, orig_run_offset)) = orig {
cluster.style = orig_style.clone();
cluster.source_content_index = *orig_source;
cluster.source_node_id = *orig_nid;
cluster.source_cluster_id.source_run = orig_source.run_index;
cluster.source_cluster_id.start_byte_in_run = (byte_pos - range_start + *orig_run_offset) as u32;
for glyph in &mut cluster.glyphs {
glyph.style = orig_style.clone();
}
if let Some(is_outside) = orig_mpo {
cluster.marker_position_outside = Some(*is_outside);
}
}
shaped.push(ShapedItem::Cluster(cluster));
}
idx = coalesce_end;
continue;
}
_total_runs += 1;
_shape_calls += 1;
let direction = if item.bidi_level.is_rtl() {
BidiDirection::Rtl
} else {
BidiDirection::Ltr
};
let language = script_to_language(item.script, &item.text);
let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
FontStack::Ref(font_ref) => {
unsafe { crate::az_mark(0x60820_u32, 0x0000_0001_u32); } if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[TextLayout] Using direct FontRef for text: '{}'",
item.text.chars().take(30).collect::<String>()
)));
}
shape_text_correctly(
&item.text,
item.script,
language,
direction,
font_ref,
style,
*source,
*source_node_id,
)
}
FontStack::Stack(selectors) => {
unsafe { crate::az_mark(0x60820_u32, 0x0000_0002_u32); } let cache_key = FontChainKey::from_selectors(selectors);
unsafe { crate::az_mark(0x60824_u32, font_chain_cache.len() as u32); }
let Some(font_chain) = font_chain_cache.get(&cache_key) else {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::warning(format!(
"[TextLayout] Font chain not pre-resolved for {:?} - text will \
not be rendered",
cache_key.font_families
)));
}
idx += 1;
continue;
};
shape_with_font_fallback(
&item.text, item.script, language, direction,
style, *source, *source_node_id,
font_chain, fc_cache, loaded_fonts,
)
}
};
let mut shaped_clusters = shaped_clusters_result?;
let run_byte_offset = item.run_byte_offset as u32;
if run_byte_offset != 0 {
for cluster in &mut shaped_clusters {
cluster.source_cluster_id.start_byte_in_run = cluster
.source_cluster_id
.start_byte_in_run
.saturating_add(run_byte_offset);
}
}
if let Some(is_outside) = marker_position_outside {
for cluster in &mut shaped_clusters {
cluster.marker_position_outside = Some(*is_outside);
}
}
shaped.extend(shaped_clusters.into_iter().map(ShapedItem::Cluster));
}
LogicalItem::Tab { source, style } => {
if style.tab_size == 0.0 {
shaped.push(ShapedItem::Tab {
source: *source,
bounds: Rect {
x: 0.0,
y: 0.0,
width: 0.0,
height: 0.0,
},
});
} else {
let space_advance_approx = style.font_size_px * SPACE_WIDTH_RATIO;
let ls = style.letter_spacing.resolve_px(style.font_size_px);
let ws = style.word_spacing.resolve_px(style.font_size_px);
let tab_interval = style.tab_size * (space_advance_approx + ls + ws);
let current_advance: f32 = shaped.iter().map(|item| {
match item {
ShapedItem::Cluster(c) => c.advance,
ShapedItem::Tab { bounds, .. } => bounds.width,
ShapedItem::Object { bounds, .. } => bounds.width,
_ => 0.0,
}
}).sum();
let next_tab_stop = ((current_advance / tab_interval).floor() + 1.0) * tab_interval;
let mut tab_width = next_tab_stop - current_advance;
let half_ch = space_advance_approx * 0.5;
if tab_width < half_ch {
tab_width += tab_interval;
}
shaped.push(ShapedItem::Tab {
source: *source,
bounds: Rect {
x: 0.0,
y: 0.0,
width: tab_width,
height: 0.0,
},
});
}
}
LogicalItem::Ruby {
source,
base_text,
ruby_text,
style,
} => {
let base_font_size = style.font_size_px;
let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
let mut annotation_props = (**style).clone();
annotation_props.font_size_px = annotation_font_size;
let annotation_style = Arc::new(annotation_props);
let base_width = measure_run_advance(
base_text, style, item.script, *source, font_chain_cache, fc_cache,
loaded_fonts,
)
.unwrap_or_else(|| base_text.chars().count() as f32 * base_font_size);
let annotation_width = measure_run_advance(
ruby_text, &annotation_style, item.script, *source, font_chain_cache,
fc_cache, loaded_fonts,
)
.unwrap_or_else(|| ruby_text.chars().count() as f32 * annotation_font_size);
let base_line_height =
style.line_height.resolve(base_font_size, 0.0, 0.0, 0.0, 0);
let annotation_line_height = annotation_style.line_height.resolve(
annotation_font_size, 0.0, 0.0, 0.0, 0,
);
let (reserved_width, reserved_height) = ruby_reserved_box(
base_width,
annotation_width,
base_line_height,
annotation_line_height,
);
shaped.push(ShapedItem::Object {
source: *source,
bounds: Rect {
x: 0.0,
y: 0.0,
width: reserved_width,
height: reserved_height,
},
baseline_offset: 0.0,
content: InlineContent::Text(StyledRun {
text: base_text.clone(),
style: style.clone(),
logical_start_byte: 0,
source_node_id: None,
}),
});
}
LogicalItem::CombinedText {
style,
source,
text,
} => {
let language = script_to_language(item.script, &item.text);
let text = if text.chars().count() > 1 {
let converted: String = text.chars().map(|c| {
let cp = c as u32;
if (0xFF01..=0xFF5E).contains(&cp) {
char::from_u32(cp - 0xFF01 + 0x0021).unwrap_or(c)
} else {
c
}
}).collect();
converted
} else {
text.clone()
};
let glyphs: Vec<Glyph> = match &style.font_stack {
FontStack::Ref(font_ref) => {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[TextLayout] Using direct FontRef for CombinedText: '{}'",
text.chars().take(30).collect::<String>()
)));
}
font_ref.shape_text(
&text,
item.script,
language,
BidiDirection::Ltr,
style.as_ref(),
)?
}
FontStack::Stack(selectors) => {
let cache_key = FontChainKey::from_selectors(selectors);
let Some(font_chain) = font_chain_cache.get(&cache_key) else {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::warning(format!(
"[TextLayout] Font chain not pre-resolved for CombinedText {:?}",
cache_key.font_families
)));
}
idx += 1;
continue;
};
let segments = split_text_by_font_coverage(&text, font_chain, fc_cache, loaded_fonts);
let mut all_glyphs = Vec::new();
for (seg_start, seg_end, font_id) in &segments {
let Some(font) = loaded_fonts.get(font_id) else { continue; };
let segment_text = &text[*seg_start..*seg_end];
let mut seg_glyphs = font.shape_text(
segment_text,
item.script,
language,
BidiDirection::Ltr,
style.as_ref(),
)?;
if *seg_start > 0 {
for g in &mut seg_glyphs {
g.logical_byte_index += *seg_start;
g.cluster += *seg_start as u32;
}
}
all_glyphs.extend(seg_glyphs);
}
if all_glyphs.is_empty() {
idx += 1;
continue;
}
all_glyphs
}
};
let shaped_glyphs: ShapedGlyphVec = glyphs
.into_iter()
.map(|g| ShapedGlyph {
kind: GlyphKind::Character,
glyph_id: g.glyph_id,
script: g.script,
font_hash: g.font_hash,
font_metrics: g.font_metrics,
style: g.style,
cluster_offset: 0,
advance: g.advance,
kerning: g.kerning,
offset: g.offset,
vertical_advance: g.vertical_advance,
vertical_offset: g.vertical_bearing,
})
.collect();
let total_width: f32 = shaped_glyphs.iter().map(|g| g.advance + g.kerning).sum();
let em_size = shaped_glyphs.first()
.map_or(style.font_size_px, |g| g.style.font_size_px);
let bounds = Rect {
x: 0.0,
y: 0.0,
width: total_width,
height: em_size,
};
shaped.push(ShapedItem::CombinedBlock {
source: *source,
glyphs: shaped_glyphs,
bounds,
baseline_offset: em_size / 2.0,
});
}
LogicalItem::Object {
content, source, ..
} => {
let (bounds, baseline) = measure_inline_object(content)?;
shaped.push(ShapedItem::Object {
source: *source,
bounds,
baseline_offset: baseline,
content: content.clone(),
});
}
LogicalItem::Break { source, break_info } => {
shaped.push(ShapedItem::Break {
source: *source,
break_info: *break_info,
});
}
}
idx += 1;
}
Ok(shaped)
}
const fn is_hanging_punctuation_char(c: char) -> bool {
matches!(c,
',' | '.' | '\u{060C}' | '\u{06D4}' | '\u{3001}' | '\u{3002}' | '\u{FF0C}' | '\u{FF0E}' | '\u{FE50}' | '\u{FE51}' | '\u{FE52}' | '\u{FF61}' | '\u{FF64}' )
}
fn is_hanging_punctuation(item: &ShapedItem) -> bool {
if let ShapedItem::Cluster(c) = item {
if c.glyphs.len() == 1 {
c.text.chars().next().is_some_and(is_hanging_punctuation_char)
} else {
false
}
} else {
false
}
}
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::too_many_lines)] fn shape_text_correctly<T: ParsedFontTrait>(
text: &str,
script: Script,
language: Language,
direction: BidiDirection,
font: &T, style: &Arc<StyleProperties>,
source_index: ContentIndex,
source_node_id: Option<NodeId>,
) -> Result<Vec<ShapedCluster>, LayoutError> {
unsafe { crate::az_mark(0x60864_u32, 0xC0DE_0864_u32); } let glyphs = font.shape_text(text, script, language, direction, style.as_ref())?;
unsafe { crate::az_mark(0x60868_u32, (glyphs.len() as u32) | 0x8000_0000_u32); }
if glyphs.is_empty() {
return Ok(Vec::new());
}
let mut clusters = Vec::new();
let mut current_cluster_glyphs = Vec::new();
let mut cluster_id = glyphs[0].cluster;
let mut cluster_start_byte_in_text = glyphs[0].logical_byte_index;
for glyph in glyphs {
if glyph.cluster != cluster_id {
let advance = current_cluster_glyphs
.iter()
.map(|g: &Glyph| g.advance)
.sum();
let (start, end) = if cluster_start_byte_in_text <= glyph.logical_byte_index {
(cluster_start_byte_in_text, glyph.logical_byte_index)
} else {
(glyph.logical_byte_index, cluster_start_byte_in_text)
};
let cluster_text = text.get(start..end).unwrap_or("");
clusters.push(ShapedCluster {
text: cluster_text.to_string(), source_cluster_id: GraphemeClusterId {
source_run: source_index.run_index,
start_byte_in_run: cluster_id,
},
source_content_index: source_index,
source_node_id,
glyphs: current_cluster_glyphs
.iter()
.map(|g| {
let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
(g.logical_byte_index - cluster_start_byte_in_text) as u32
} else {
0
};
ShapedGlyph {
kind: if g.glyph_id == 0 {
GlyphKind::NotDef
} else {
GlyphKind::Character
},
glyph_id: g.glyph_id,
script: g.script,
font_hash: g.font_hash,
font_metrics: g.font_metrics,
style: g.style.clone(),
cluster_offset,
advance: g.advance,
kerning: g.kerning,
vertical_advance: g.vertical_advance,
vertical_offset: g.vertical_bearing,
offset: g.offset,
}
})
.collect(),
advance,
direction,
style: style.clone(),
marker_position_outside: None,
is_first_fragment: true,
is_last_fragment: true,
});
current_cluster_glyphs.clear();
cluster_id = glyph.cluster;
cluster_start_byte_in_text = glyph.logical_byte_index;
}
current_cluster_glyphs.push(glyph);
}
if !current_cluster_glyphs.is_empty() {
let advance = current_cluster_glyphs
.iter()
.map(|g: &Glyph| g.advance)
.sum();
let cluster_text = text.get(cluster_start_byte_in_text..).unwrap_or("");
clusters.push(ShapedCluster {
text: cluster_text.to_string(), source_cluster_id: GraphemeClusterId {
source_run: source_index.run_index,
start_byte_in_run: cluster_id,
},
source_content_index: source_index,
source_node_id,
glyphs: current_cluster_glyphs
.iter()
.map(|g| {
let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
(g.logical_byte_index - cluster_start_byte_in_text) as u32
} else {
0
};
ShapedGlyph {
kind: if g.glyph_id == 0 {
GlyphKind::NotDef
} else {
GlyphKind::Character
},
glyph_id: g.glyph_id,
font_hash: g.font_hash,
font_metrics: g.font_metrics,
style: g.style.clone(),
script: g.script,
vertical_advance: g.vertical_advance,
vertical_offset: g.vertical_bearing,
cluster_offset,
advance: g.advance,
kerning: g.kerning,
offset: g.offset,
}
})
.collect(),
advance,
direction,
style: style.clone(),
marker_position_outside: None,
is_first_fragment: true,
is_last_fragment: true,
});
}
Ok(clusters)
}
fn measure_inline_object(item: &InlineContent) -> Result<(Rect, f32), LayoutError> {
match item {
InlineContent::Image(img) => {
let size = img.display_size.unwrap_or(img.intrinsic_size);
Ok((
Rect {
x: 0.0,
y: 0.0,
width: size.width,
height: size.height,
},
img.baseline_offset,
))
}
InlineContent::Shape(shape) => Ok({
let size = shape.shape_def.get_size();
(
Rect {
x: 0.0,
y: 0.0,
width: size.width,
height: size.height,
},
shape.baseline_offset,
)
}),
InlineContent::Space(space) => Ok((
Rect {
x: 0.0,
y: 0.0,
width: space.width,
height: 0.0,
},
0.0,
)),
InlineContent::Marker { .. } => {
Err(LayoutError::InvalidText(
"Marker is text content, not a measurable object".into(),
))
}
_ => Err(LayoutError::InvalidText("Not a measurable object".into())),
}
}
fn apply_text_orientation(
items: Arc<Vec<ShapedItem>>,
constraints: &UnifiedConstraints,
) -> Arc<Vec<ShapedItem>> {
if !constraints.is_vertical() {
return items;
}
let mut oriented_items = Vec::with_capacity(items.len());
let writing_mode = constraints.writing_mode.unwrap_or_default();
for item in items.iter() {
match item {
ShapedItem::Cluster(cluster) => {
let mut new_cluster = cluster.clone();
let mut total_vertical_advance = 0.0;
for glyph in &mut new_cluster.glyphs {
if glyph.vertical_advance > 0.0 {
total_vertical_advance += glyph.vertical_advance;
} else {
let fallback_advance = cluster.style.line_height.resolve_with_metrics(cluster.style.font_size_px, &glyph.font_metrics);
glyph.vertical_advance = fallback_advance;
glyph.vertical_offset = Point {
x: -glyph.advance / 2.0,
y: 0.0,
};
total_vertical_advance += fallback_advance;
}
}
new_cluster.advance = total_vertical_advance;
oriented_items.push(ShapedItem::Cluster(new_cluster));
}
ShapedItem::Object {
source,
bounds,
baseline_offset,
content,
} => {
let mut new_bounds = *bounds;
std::mem::swap(&mut new_bounds.width, &mut new_bounds.height);
oriented_items.push(ShapedItem::Object {
source: *source,
bounds: new_bounds,
baseline_offset: *baseline_offset,
content: content.clone(),
});
}
_ => oriented_items.push(item.clone()),
}
}
Arc::new(oriented_items)
}
fn get_item_vertical_align(item: &ShapedItem) -> Option<VerticalAlign> {
match item {
ShapedItem::Object { content, .. } => match content {
InlineContent::Image(img) => Some(img.alignment),
InlineContent::Shape(shape) => Some(shape.alignment),
_ => None,
},
ShapedItem::Cluster(c) => match c.style.vertical_align {
VerticalAlign::Baseline => None,
va => Some(va),
},
_ => None,
}
}
#[allow(clippy::match_same_arms)] #[must_use] pub fn get_item_vertical_metrics_approx(item: &ShapedItem) -> (f32, f32) {
if let ShapedItem::Cluster(c) = item {
if !c.glyphs.is_empty() {
let (asc, desc) = c.glyphs
.iter()
.fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
let metrics = &glyph.font_metrics;
if metrics.units_per_em == 0 {
return (max_asc, max_desc);
}
let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
let font_ascent = metrics.ascent * scale;
let font_descent = (-metrics.descent * scale).max(0.0);
let ad = font_ascent + font_descent;
let resolved_lh = c.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
let half_leading = (resolved_lh - ad) / 2.0;
(max_asc.max(font_ascent + half_leading), max_desc.max(font_descent + half_leading))
});
return (asc, desc);
}
}
match item {
ShapedItem::Cluster(c) => {
let lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
(lh * FALLBACK_ASCENT_RATIO, lh * FALLBACK_DESCENT_RATIO)
}
ShapedItem::CombinedBlock { bounds, .. } => {
(bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
}
ShapedItem::Object { bounds, .. } => (bounds.height, 0.0),
ShapedItem::Tab { bounds, .. } => {
(bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
}
ShapedItem::Break { .. } => (0.0, 0.0),
}
}
#[must_use] pub fn get_item_vertical_metrics(item: &ShapedItem, constraints: &UnifiedConstraints) -> (f32, f32) {
match item {
ShapedItem::Cluster(c) => {
if c.glyphs.is_empty() {
let ad = constraints.strut_ascent + constraints.strut_descent;
let resolved_lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
let half_leading = (resolved_lh - ad) / 2.0;
return (constraints.strut_ascent + half_leading, constraints.strut_descent + half_leading);
}
c.glyphs
.iter()
.fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
let metrics = &glyph.font_metrics;
if metrics.units_per_em == 0 {
return (max_asc, max_desc);
}
let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
let a = metrics.ascent * scale;
let d = (-metrics.descent * scale).max(0.0);
let ad = a + d;
let resolved_lh = glyph.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
let leading = resolved_lh - ad;
let half_leading = leading / 2.0;
let item_asc = a + half_leading;
let item_desc = d + half_leading;
(max_asc.max(item_asc), max_desc.max(item_desc))
})
}
ShapedItem::Object {
bounds,
baseline_offset,
..
} => {
let ascent = bounds.height - *baseline_offset;
let descent = *baseline_offset;
(ascent.max(0.0), descent.max(0.0))
}
ShapedItem::CombinedBlock {
bounds,
baseline_offset,
..
} => {
let ascent = bounds.height - *baseline_offset;
let descent = *baseline_offset;
(ascent.max(0.0), descent.max(0.0))
}
_ => (0.0, 0.0), }
}
fn calculate_line_metrics(
items: &[ShapedItem],
default_vertical_align: VerticalAlign,
constraints: &UnifiedConstraints,
) -> (f32, f32) {
let (mut max_asc, mut max_desc) = items
.iter()
.fold((0.0f32, 0.0f32), |(max_asc, max_desc), item| {
let effective_align = get_item_vertical_align(item)
.unwrap_or(default_vertical_align);
match effective_align {
VerticalAlign::Top | VerticalAlign::Bottom => {
(max_asc, max_desc)
}
_ => {
let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
(max_asc.max(item_asc), max_desc.max(item_desc))
}
}
});
let baseline_line_height = max_asc + max_desc;
for item in items {
let effective_align = get_item_vertical_align(item)
.unwrap_or(default_vertical_align);
match effective_align {
VerticalAlign::Top | VerticalAlign::Bottom => {
let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
let item_height = item_asc + item_desc;
if item_height > baseline_line_height {
if effective_align == VerticalAlign::Top {
max_desc = max_desc.max(item_height - max_asc);
} else {
max_asc = max_asc.max(item_height - max_desc);
}
}
}
_ => {} }
}
(max_asc, max_desc)
}
fn apply_l2_visual_reversal(line_items: &mut [ShapedItem]) {
let dir_of = |it: &ShapedItem| it.as_cluster().map(|c| c.direction);
let mut i = 0;
while i < line_items.len() {
let Some(dir) = dir_of(&line_items[i]) else {
i += 1;
continue;
};
let mut j = i + 1;
while j < line_items.len() && dir_of(&line_items[j]) == Some(dir) {
j += 1;
}
if dir == BidiDirection::Rtl {
line_items[i..j].reverse();
}
i = j;
}
}
#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] pub fn perform_fragment_layout<T: ParsedFontTrait>(
cursor: &mut BreakCursor<'_>,
logical_items: &[LogicalItem],
fragment_constraints: &UnifiedConstraints,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
fonts: &LoadedFonts<T>,
) -> Result<UnifiedLayout, LayoutError> {
const MAX_EMPTY_SEGMENTS: usize = 1000; if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"\n--- Entering perform_fragment_layout ---".to_string(),
));
msgs.push(LayoutDebugMessage::info(format!(
"Constraints: available_width={:?}, available_height={:?}, columns={}, text_wrap={:?}",
fragment_constraints.available_width,
fragment_constraints.available_height,
fragment_constraints.columns,
fragment_constraints.text_wrap
)));
}
if fragment_constraints.text_wrap == TextWrap::Balance {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"Using Knuth-Plass algorithm for text-wrap: balance".to_string(),
));
}
let shaped_items: Vec<ShapedItem> = cursor.drain_remaining();
let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
fragment_constraints
.hyphenation_language
.and_then(|lang| get_hyphenator(lang).ok())
} else {
None
};
return Ok(crate::text3::knuth_plass::kp_layout(
&shaped_items,
logical_items,
fragment_constraints,
hyphenator.as_ref(),
fonts,
));
}
let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
fragment_constraints
.hyphenation_language
.and_then(|lang| get_hyphenator(lang).ok())
} else {
None
};
let mut positioned_items = Vec::new();
let mut layout_bounds = Rect::default();
let num_columns = fragment_constraints.columns.max(1);
let total_column_gap = fragment_constraints.column_gap * (num_columns - 1) as f32;
let is_min_content = matches!(fragment_constraints.available_width, AvailableSpace::MinContent);
let is_max_content = matches!(fragment_constraints.available_width, AvailableSpace::MaxContent);
let column_width = match fragment_constraints.available_width {
AvailableSpace::Definite(width) => (width - total_column_gap) / num_columns as f32,
AvailableSpace::MinContent | AvailableSpace::MaxContent => {
f32::MAX / 2.0
}
};
let mut current_column = 0;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Column width calculated: {column_width}"
)));
}
let base_direction = if fragment_constraints.unicode_bidi == UnicodeBidi::Plaintext {
let remaining = &cursor.items[cursor.next_item_index..];
let text: String = remaining.iter()
.filter_map(|i| i.as_cluster())
.map(|c| c.text.as_str())
.collect();
match unicode_bidi::get_base_direction(text.as_str()) {
unicode_bidi::Direction::Ltr => BidiDirection::Ltr,
unicode_bidi::Direction::Rtl => BidiDirection::Rtl,
unicode_bidi::Direction::Mixed => fragment_constraints.direction.unwrap_or(BidiDirection::Ltr),
}
} else {
fragment_constraints.direction.unwrap_or(BidiDirection::Ltr)
};
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[PFLayout] Base direction: {:?} (from CSS), Text align: {:?}",
base_direction, fragment_constraints.text_align
)));
}
let balanced_lines_per_column: Option<usize> = if num_columns > 1
&& fragment_constraints.shape_boundaries.is_empty()
&& !is_min_content
&& !is_max_content
{
let mut probe = cursor.clone();
let mut probe_col_constraints = fragment_constraints.clone();
probe_col_constraints.available_width = AvailableSpace::Definite(column_width);
let probe_line_height = fragment_constraints.resolved_line_height();
let iter_cap = probe.items.len().saturating_mul(4).max(64);
let mut total_lines = 0usize;
let mut probe_y = 0.0_f32;
let mut probe_guard = 0usize;
while !probe.is_done() && probe_guard < iter_cap {
probe_guard += 1;
let lc = get_line_constraints(probe_y, probe_line_height, &probe_col_constraints, &mut None);
if lc.segments.is_empty() {
break;
}
let (probe_line, _) = break_one_line(
&mut probe,
&lc,
false,
hyphenator.as_ref(),
fonts,
fragment_constraints.line_break,
fragment_constraints.white_space_mode,
fragment_constraints.overflow_wrap,
);
if probe_line.is_empty() {
break;
}
total_lines += 1;
probe_y += probe_line_height;
}
(total_lines > 0).then(|| total_lines.div_ceil(num_columns as usize).max(1))
} else {
None
};
'column_loop: while current_column < num_columns {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"\n-- Starting Column {current_column} --"
)));
}
let column_start_x =
(column_width + fragment_constraints.column_gap) * current_column as f32;
let mut line_top_y = 0.0;
let mut line_index = 0;
let mut empty_segment_count = 0; let mut is_after_forced_break = false;
let column_item_start = positioned_items.len();
let mut line_bands: Vec<(usize, f32, f32)> = Vec::new();
#[allow(clippy::no_effect_underscore_binding)] let mut _az_line_iters: usize = 0;
while !cursor.is_done() {
#[cfg(feature = "web_lift")]
{
_az_line_iters += 1;
unsafe { crate::az_mark((0x60BC4) as u32, (_az_line_iters as u32 | 0xC0DE0000) as u32); }
if _az_line_iters > 4096 {
break;
}
}
if let Some(max_height) = fragment_constraints.available_height {
if line_top_y >= max_height {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" Column full (pen {line_top_y} >= height {max_height}), breaking to next column."
)));
}
break;
}
}
if let Some(clamp) = fragment_constraints.line_clamp {
if line_index >= clamp.get() {
break;
}
}
if let Some(budget) = balanced_lines_per_column {
if current_column + 1 < num_columns && line_index >= budget {
break;
}
}
let mut column_constraints = fragment_constraints.clone();
if is_min_content {
column_constraints.available_width = AvailableSpace::MinContent;
} else if is_max_content {
column_constraints.available_width = AvailableSpace::MaxContent;
} else {
column_constraints.available_width = AvailableSpace::Definite(column_width);
}
let line_constraints = get_line_constraints(
line_top_y,
fragment_constraints.resolved_line_height(),
&column_constraints,
debug_messages,
);
if line_constraints.segments.is_empty() {
empty_segment_count += 1;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" No available segments at y={line_top_y}, skipping to next line. (empty count: \
{empty_segment_count}/{MAX_EMPTY_SEGMENTS})"
)));
}
if empty_segment_count >= MAX_EMPTY_SEGMENTS {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::warning(format!(
" [WARN] Reached maximum empty segment count ({MAX_EMPTY_SEGMENTS}). Breaking to \
prevent infinite loop."
)));
msgs.push(LayoutDebugMessage::warning(
" This likely means the shape constraints are too restrictive or \
positioned incorrectly."
.to_string(),
));
msgs.push(LayoutDebugMessage::warning(format!(
" Current y={line_top_y}, shape boundaries might be outside this range."
)));
}
break;
}
if !fragment_constraints.shape_boundaries.is_empty() && empty_segment_count > 50 {
let max_shape_y: f32 = fragment_constraints
.shape_boundaries
.iter()
.map(|shape| {
match shape {
ShapeBoundary::Circle { center, radius } => center.y + radius,
ShapeBoundary::Ellipse { center, radii } => center.y + radii.height,
ShapeBoundary::Polygon { points } => {
points.iter().map(|p| p.y).fold(0.0, f32::max)
}
ShapeBoundary::Rectangle(rect) => rect.y + rect.height,
ShapeBoundary::Path { segments } => segments
.iter()
.filter_map(|s| match s {
PathSegment::MoveTo(p) | PathSegment::LineTo(p) => Some(p.y),
PathSegment::CurveTo { end, .. }
| PathSegment::QuadTo { end, .. } => Some(end.y),
PathSegment::Arc { center, radius, .. } => {
Some(center.y + radius)
}
PathSegment::Close => None,
})
.fold(0.0, f32::max),
}
})
.fold(0.0, f32::max);
if line_top_y > max_shape_y + 100.0 {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" [INFO] Current y={line_top_y} is far beyond maximum shape extent y={max_shape_y}. \
Breaking layout."
)));
msgs.push(LayoutDebugMessage::info(
" Shape boundaries exist but no segments available - text cannot \
fit in shape."
.to_string(),
));
}
break;
}
}
line_top_y += fragment_constraints.resolved_line_height();
continue;
}
empty_segment_count = 0;
let effective_overflow_wrap = if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::Anywhere {
OverflowWrap::Anywhere
} else if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::BreakWord {
OverflowWrap::Normal
} else {
fragment_constraints.overflow_wrap
};
let (mut line_items, was_hyphenated) =
break_one_line(cursor, &line_constraints, false, hyphenator.as_ref(), fonts, fragment_constraints.line_break, fragment_constraints.white_space_mode, effective_overflow_wrap);
if line_items.is_empty() {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
" Break returned no items. Ending column.".to_string(),
));
}
break;
}
let line_text_before_rev: String = line_items
.iter()
.filter_map(|i| i.as_cluster())
.map(|c| c.text.as_str())
.collect();
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[PFLayout] Line items from breaker (visual order): [{line_text_before_rev}]"
)));
}
apply_l2_visual_reversal(&mut line_items);
if let Some(msgs) = debug_messages {
let after: String = line_items
.iter()
.filter_map(|i| i.as_cluster())
.map(|c| c.text.as_str())
.collect();
if after != line_text_before_rev {
msgs.push(LayoutDebugMessage::info(format!(
"[PFLayout] Line items after L2 reversal: [{after}]"
)));
}
}
let line_ends_with_forced_break = line_items.iter().any(|item| matches!(item, ShapedItem::Break { .. }));
let is_last_line = cursor.is_done() && !was_hyphenated;
let effective_align = resolve_effective_alignment(
fragment_constraints.text_align,
fragment_constraints.text_align_last,
is_last_line || line_ends_with_forced_break,
);
let (mut line_pos_items, line_height) = position_one_line(
&line_items,
&line_constraints,
line_top_y,
line_index,
effective_align,
base_direction,
is_last_line,
fragment_constraints,
debug_messages,
fonts,
is_after_forced_break,
);
is_after_forced_break = line_ends_with_forced_break;
for item in &mut line_pos_items {
item.position.x += column_start_x;
}
let band_height = line_height.max(fragment_constraints.resolved_line_height());
line_bands.push((line_index, line_top_y, band_height));
line_top_y += band_height;
line_index += 1;
positioned_items.extend(line_pos_items);
}
if fragment_constraints.writing_mode == Some(WritingMode::VerticalRl) {
let block_extent = line_top_y;
for item in &mut positioned_items[column_item_start..] {
if let Some(&(_, t, h)) =
line_bands.iter().find(|(li, _, _)| *li == item.line_index)
{
item.position.x += block_extent - t - t - h;
}
}
}
current_column += 1;
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"--- Exiting perform_fragment_layout, positioned {} items ---",
positioned_items.len()
)));
}
let mut layout = UnifiedLayout {
items: positioned_items,
overflow: OverflowInfo::default(),
};
let calculated_bounds = layout.bounds();
layout.overflow.unclipped_bounds = calculated_bounds;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"--- Calculated bounds: width={}, height={} ---",
calculated_bounds.width, calculated_bounds.height
)));
}
Ok(layout)
}
#[allow(clippy::cognitive_complexity)] #[allow(clippy::too_many_lines)] pub fn break_one_line<T: ParsedFontTrait>(
cursor: &mut BreakCursor<'_>,
line_constraints: &LineConstraints,
is_vertical: bool,
hyphenator: Option<&Standard>,
fonts: &LoadedFonts<T>,
line_break: LineBreakStrictness,
white_space_mode: WhiteSpaceMode,
overflow_wrap: OverflowWrap,
) -> (Vec<ShapedItem>, bool) {
let mut line_items = Vec::new();
let mut current_width = 0.0;
if cursor.is_done() {
return (Vec::new(), false);
}
let strip_leading = matches!(
white_space_mode,
WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
);
if strip_leading {
while !cursor.is_done() {
let next_unit = cursor.peek_next_unit();
if next_unit.is_empty() {
break;
}
if next_unit.len() == 1 && is_collapsible_whitespace(&next_unit[0]) {
cursor.consume(1);
} else {
break;
}
}
}
let no_wrap = matches!(white_space_mode, WhiteSpaceMode::Nowrap | WhiteSpaceMode::Pre);
if no_wrap {
loop {
let next_unit = cursor.peek_next_unit();
if next_unit.is_empty() {
break;
}
if let Some(ShapedItem::Break { .. }) = next_unit.first() {
line_items.push(next_unit[0].clone());
cursor.consume(1);
return (line_items, false);
}
line_items.extend_from_slice(&next_unit);
cursor.consume(next_unit.len());
}
} else {
loop {
let next_unit = if line_break == LineBreakStrictness::Anywhere {
cursor.peek_next_single_item()
} else {
cursor.peek_next_unit()
};
if next_unit.is_empty() {
break; }
if let Some(ShapedItem::Break { .. }) = next_unit.first() {
line_items.push(next_unit[0].clone());
cursor.consume(1);
return (line_items, false);
}
if line_constraints.is_min_content
&& !line_items.is_empty()
&& next_unit.len() == 1
&& is_break_opportunity_with_word_break(&next_unit[0], cursor.word_break, cursor.hyphens)
{
break;
}
let unit_width: f32 = next_unit
.iter()
.map(|item| get_item_measure_with_spacing(item, is_vertical))
.sum();
let available_width = line_constraints.total_available - current_width;
if unit_width <= available_width {
line_items.extend_from_slice(&next_unit);
current_width += unit_width;
cursor.consume(next_unit.len());
} else {
if line_break != LineBreakStrictness::Anywhere {
if let Some(hyphenator) = hyphenator {
if !is_break_opportunity(next_unit.last().unwrap()) {
if let Some(hyphenation_result) = try_hyphenate_word_cluster(
&next_unit,
available_width,
is_vertical,
hyphenator,
fonts,
) {
line_items.extend(hyphenation_result.line_part);
cursor.consume(next_unit.len());
cursor.partial_remainder = hyphenation_result.remainder_part;
return (line_items, true);
}
}
}
}
if line_items.is_empty() {
match overflow_wrap {
OverflowWrap::Anywhere | OverflowWrap::BreakWord => {
let avail = line_constraints.total_available;
for item in &next_unit {
let item_w = get_item_measure_with_spacing(item, is_vertical);
if !line_items.is_empty() && avail > 0.0 && current_width + item_w > avail {
break;
}
line_items.push(item.clone());
current_width += item_w;
}
let consumed = line_items.len().max(1);
if line_items.is_empty() {
line_items.push(next_unit[0].clone());
}
cursor.consume(consumed);
}
OverflowWrap::Normal => {
line_items.extend_from_slice(&next_unit);
cursor.consume(next_unit.len());
}
}
}
break;
}
}
}
let strip_trailing = matches!(
white_space_mode,
WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
);
if strip_trailing {
while let Some(last) = line_items.last() {
if is_collapsible_whitespace(last) {
line_items.pop();
} else {
break;
}
}
}
(line_items, false)
}
#[derive(Debug, Clone)]
pub struct HyphenationBreak {
pub char_len_on_line: usize,
pub width_on_line: f32,
pub line_part: Vec<ShapedItem>,
pub hyphen_item: ShapedItem,
pub remainder_part: Vec<ShapedItem>,
}
#[allow(clippy::cast_precision_loss)] #[must_use] pub fn find_all_hyphenation_breaks<T: ParsedFontTrait>(
word_clusters: &[ShapedCluster],
hyphenator: &Standard,
is_vertical: bool, fonts: &LoadedFonts<T>,
) -> Option<Vec<HyphenationBreak>> {
if word_clusters.is_empty() {
return None;
}
let mut word_string = String::new();
let mut char_map = Vec::new();
let mut current_width = 0.0;
for (cluster_idx, cluster) in word_clusters.iter().enumerate() {
for (char_byte_offset, _ch) in cluster.text.char_indices() {
let glyph_idx = cluster
.glyphs
.iter()
.rposition(|g| g.cluster_offset as usize <= char_byte_offset)
.unwrap_or(0);
let glyph = &cluster.glyphs[glyph_idx];
let num_chars_in_glyph = cluster.text[glyph.cluster_offset as usize..]
.chars()
.count();
let advance_per_char = if is_vertical {
glyph.vertical_advance
} else {
glyph.advance
} / (num_chars_in_glyph as f32).max(1.0);
current_width += advance_per_char;
char_map.push((cluster_idx, glyph_idx, current_width));
}
word_string.push_str(&cluster.text);
}
let opportunities = hyphenator.hyphenate(&word_string);
if opportunities.breaks.is_empty() {
return None;
}
let last_cluster = word_clusters.last().unwrap();
let last_glyph = last_cluster.glyphs.last().unwrap();
let style = last_cluster.style.clone();
let font = fonts.get_by_hash(last_glyph.font_hash)?;
let (hyphen_glyph_id, hyphen_advance) =
font.get_hyphen_glyph_and_advance(style.font_size_px)?;
let mut possible_breaks = Vec::new();
for &break_char_idx in &opportunities.breaks {
if break_char_idx == 0 || break_char_idx > char_map.len() {
continue;
}
let (_, _, width_at_break) = char_map[break_char_idx - 1];
let line_part: Vec<ShapedItem> = word_clusters[..break_char_idx]
.iter()
.map(|c| ShapedItem::Cluster(c.clone()))
.collect();
let remainder_part: Vec<ShapedItem> = word_clusters[break_char_idx..]
.iter()
.map(|c| ShapedItem::Cluster(c.clone()))
.collect();
let hyphen_item = ShapedItem::Cluster(ShapedCluster {
text: "-".to_string(),
source_cluster_id: GraphemeClusterId {
source_run: u32::MAX,
start_byte_in_run: u32::MAX,
},
source_content_index: ContentIndex {
run_index: u32::MAX,
item_index: u32::MAX,
},
source_node_id: None, glyphs: smallvec![ShapedGlyph {
kind: GlyphKind::Hyphen,
glyph_id: hyphen_glyph_id,
font_hash: last_glyph.font_hash,
font_metrics: last_glyph.font_metrics,
cluster_offset: 0,
script: Script::Latin,
advance: hyphen_advance,
kerning: 0.0,
offset: Point::default(),
style: style.clone(),
vertical_advance: hyphen_advance,
vertical_offset: Point::default(),
}],
advance: hyphen_advance,
direction: BidiDirection::Ltr,
style: style.clone(),
marker_position_outside: None,
is_first_fragment: true,
is_last_fragment: true,
});
possible_breaks.push(HyphenationBreak {
char_len_on_line: break_char_idx,
width_on_line: width_at_break + hyphen_advance,
line_part,
hyphen_item,
remainder_part,
});
}
Some(possible_breaks)
}
fn try_hyphenate_word_cluster<T: ParsedFontTrait>(
word_items: &[ShapedItem],
remaining_width: f32,
is_vertical: bool,
hyphenator: &Standard,
fonts: &LoadedFonts<T>,
) -> Option<HyphenationResult> {
let word_clusters: Vec<ShapedCluster> = word_items
.iter()
.filter_map(|item| item.as_cluster().cloned())
.collect();
if word_clusters.is_empty() {
return None;
}
let all_breaks = find_all_hyphenation_breaks(&word_clusters, hyphenator, is_vertical, fonts)?;
if let Some(best_break) = all_breaks
.into_iter()
.rfind(|b| b.width_on_line <= remaining_width)
{
let mut line_part = best_break.line_part;
line_part.push(best_break.hyphen_item);
return Some(HyphenationResult {
line_part,
remainder_part: best_break.remainder_part,
});
}
None
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_precision_loss)] #[allow(clippy::match_same_arms)] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] pub fn position_one_line<T: ParsedFontTrait>(
line_items: &[ShapedItem],
line_constraints: &LineConstraints,
line_top_y: f32,
line_index: usize,
text_align: TextAlign,
base_direction: BidiDirection,
is_last_line: bool,
constraints: &UnifiedConstraints,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
fonts: &LoadedFonts<T>,
is_after_forced_break: bool,
) -> (Vec<PositionedItem>, f32) {
let line_text: String = line_items
.iter()
.filter_map(|i| i.as_cluster())
.map(|c| c.text.as_str())
.collect();
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"\n--- Entering position_one_line for line: [{line_text}] ---"
)));
}
let physical_align = match (text_align, base_direction) {
(TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
(TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
(TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
(TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
(other, _) => other,
};
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[Pos1Line] Physical align: {physical_align:?}"
)));
}
if line_items.is_empty() {
return (Vec::new(), 0.0);
}
let mut positioned = Vec::new();
let is_vertical = constraints.is_vertical();
let (content_ascent, content_descent) = calculate_line_metrics(line_items, constraints.vertical_align, constraints);
let strut_ad = constraints.strut_ascent + constraints.strut_descent;
let strut_leading_half = (constraints.resolved_line_height() - strut_ad) / 2.0;
let strut_above = constraints.strut_ascent + strut_leading_half;
let strut_below = constraints.strut_descent + strut_leading_half;
let line_ascent = content_ascent.max(strut_above);
let line_descent = content_descent.max(strut_below);
let line_box_height = line_ascent + line_descent;
let line_baseline_y = line_top_y + line_ascent;
let mut item_cursor = 0;
let is_first_line_of_para = line_index == 0;
for (segment_idx, segment) in line_constraints.segments.iter().enumerate() {
if item_cursor >= line_items.len() {
break;
}
let mut segment_items = Vec::new();
let mut current_segment_width = 0.0;
while item_cursor < line_items.len() {
let item = &line_items[item_cursor];
let item_measure = get_item_measure(item, is_vertical);
if current_segment_width + item_measure > segment.width && !segment_items.is_empty() {
break;
}
segment_items.push(item.clone());
current_segment_width += item_measure;
item_cursor += 1;
}
if segment_items.is_empty() {
continue;
}
let (extra_word_spacing, extra_char_spacing) = if (constraints.text_align == TextAlign::Justify
|| constraints.text_align == TextAlign::JustifyAll)
&& constraints.text_justify != JustifyContent::None
&& (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
&& constraints.text_justify != JustifyContent::Kashida
{
let segment_line_constraints = LineConstraints {
segments: vec![*segment],
total_available: segment.width,
is_min_content: false,
};
calculate_justification_spacing(
&segment_items,
&segment_line_constraints,
constraints.text_justify,
is_vertical,
)
} else {
(0.0, 0.0)
};
let justified_segment_items = if constraints.text_justify == JustifyContent::Kashida
&& (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
{
let segment_line_constraints = LineConstraints {
segments: vec![*segment],
total_available: segment.width,
is_min_content: false,
};
justify_kashida_and_rebuild(
segment_items,
&segment_line_constraints,
is_vertical,
debug_messages,
fonts,
)
} else {
segment_items
};
let final_segment_width: f32 = justified_segment_items
.iter()
.map(|item| get_item_measure(item, is_vertical))
.sum();
let trailing_ws_width = match constraints.white_space_mode {
WhiteSpaceMode::BreakSpaces | WhiteSpaceMode::Pre => 0.0,
WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine => {
measure_trailing_whitespace(&justified_segment_items, is_vertical)
}
WhiteSpaceMode::PreWrap => {
let has_forced_break = justified_segment_items.last()
.is_some_and(|item| matches!(item, ShapedItem::Break { .. }));
let ws_width = measure_trailing_whitespace(&justified_segment_items, is_vertical);
if has_forced_break {
let content_width = final_segment_width - ws_width;
if content_width + ws_width > segment.width {
ws_width
} else {
0.0
}
} else {
ws_width }
}
};
let effective_segment_width = final_segment_width - trailing_ws_width;
let remaining_space = segment.width - effective_segment_width;
let is_indefinite_width = segment.width.is_infinite() || segment.width > 1e30;
let alignment_offset = if is_indefinite_width {
0.0 } else {
match physical_align {
TextAlign::Center => remaining_space / 2.0,
TextAlign::Right => remaining_space,
TextAlign::Justify | TextAlign::JustifyAll
if remaining_space > 0.0
&& extra_word_spacing == 0.0
&& extra_char_spacing == 0.0 =>
{
remaining_space / 2.0
}
_ => 0.0, }
};
let mut main_axis_pen = segment.start_x + alignment_offset;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[Pos1Line] Segment width: {}, Item width: {}, Remaining space: {}, Initial pen: \
{}",
segment.width, final_segment_width, remaining_space, main_axis_pen
)));
}
if segment_idx == 0 {
let is_indent_target = if constraints.text_indent_each_line {
is_first_line_of_para || is_after_forced_break
} else {
is_first_line_of_para
};
let should_indent = if constraints.text_indent_hanging {
!is_indent_target
} else {
is_indent_target
};
if should_indent {
main_axis_pen += constraints.text_indent;
}
}
let total_marker_width: f32 = justified_segment_items
.iter()
.filter_map(|item| {
if let ShapedItem::Cluster(c) = item {
if c.marker_position_outside == Some(true) {
return Some(get_item_measure(item, is_vertical));
}
}
None
})
.sum();
let marker_spacing = 4.0; let mut marker_pen = if total_marker_width > 0.0 {
-(total_marker_width + marker_spacing)
} else {
0.0
};
let inline_offsets: Vec<(f32, f32)> = {
let items_slice: &[ShapedItem] = &justified_segment_items;
items_slice.iter().enumerate().map(|(idx, item)| {
if let ShapedItem::Cluster(c) = item {
if let Some(border) = c.style.border.as_ref() {
if border.has_chrome() {
let style_ptr = Arc::as_ptr(&c.style);
let prev_same_span = idx > 0 && items_slice[idx - 1]
.as_cluster()
.is_some_and(|pc| Arc::as_ptr(&pc.style) == style_ptr);
let next_same_span = idx + 1 < items_slice.len() && items_slice[idx + 1]
.as_cluster()
.is_some_and(|nc| Arc::as_ptr(&nc.style) == style_ptr);
let left = if prev_same_span { 0.0 } else { border.left_inset() };
let right = if next_same_span { 0.0 } else { border.right_inset() };
return (left, right);
}
}
}
(0.0, 0.0)
}).collect()
};
for (inline_offset_idx, item) in justified_segment_items.into_iter().enumerate() {
let (item_ascent, item_descent) = get_item_vertical_metrics(&item, constraints);
let effective_align = get_item_vertical_align(&item)
.unwrap_or(constraints.vertical_align);
let item_baseline_pos = match effective_align {
VerticalAlign::Top => line_top_y + item_ascent,
VerticalAlign::Middle => {
let half_x_height = constraints.strut_x_height / 2.0;
line_baseline_y + half_x_height - f32::midpoint(item_ascent, item_descent) + item_ascent
}
VerticalAlign::Bottom => line_top_y + line_box_height - item_descent,
VerticalAlign::Sub => line_baseline_y + line_ascent * SUBSCRIPT_OFFSET_RATIO,
VerticalAlign::Super => line_baseline_y - line_ascent * SUPERSCRIPT_OFFSET_RATIO,
VerticalAlign::TextTop => (line_baseline_y - constraints.strut_ascent) + item_ascent,
VerticalAlign::TextBottom => (line_baseline_y + constraints.strut_descent) - item_descent,
VerticalAlign::Offset(offset) => line_baseline_y - offset,
VerticalAlign::Baseline => line_baseline_y,
};
let item_measure = get_item_measure(&item, is_vertical);
let (left_inset, right_inset) = if inline_offset_idx < inline_offsets.len() {
inline_offsets[inline_offset_idx]
} else {
(0.0, 0.0)
};
main_axis_pen += left_inset;
let position = if is_vertical {
Point {
x: item_baseline_pos - item_ascent,
y: main_axis_pen,
}
} else {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[Pos1Line] is_vertical=false, main_axis_pen={main_axis_pen}, item_baseline_pos={item_baseline_pos}, \
item_ascent={item_ascent}"
)));
}
let x_position = if let ShapedItem::Cluster(cluster) = &item {
if cluster.marker_position_outside == Some(true) {
let marker_width = item_measure;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[Pos1Line] Outside marker detected! width={marker_width}, positioning at \
marker_pen={marker_pen}"
)));
}
let pos = marker_pen;
marker_pen += marker_width; pos
} else {
main_axis_pen
}
} else {
main_axis_pen
};
Point {
y: item_baseline_pos - item_ascent,
x: x_position,
}
};
let item_text = item
.as_cluster()
.map_or("[OBJ]", |c| c.text.as_str());
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"[Pos1Line] Positioning item '{item_text}' at pen_x={main_axis_pen}"
)));
}
positioned.push(PositionedItem {
item: item.clone(),
position,
line_index,
});
let is_outside_marker = if let ShapedItem::Cluster(c) = &item {
c.marker_position_outside == Some(true)
} else {
false
};
if !is_outside_marker {
main_axis_pen += item_measure;
main_axis_pen += right_inset;
}
let is_cursive = if let ShapedItem::Cluster(c) = &item { is_cursive_script_cluster(c) } else { false };
if !is_outside_marker && extra_char_spacing > 0.0 && can_justify_after(&item) && !is_cursive {
main_axis_pen += extra_char_spacing;
}
if let ShapedItem::Cluster(c) = &item {
if !is_outside_marker {
if !is_cursive_script_cluster(c) {
let letter_spacing_px = c.style.letter_spacing.resolve_px(c.style.font_size_px);
main_axis_pen += letter_spacing_px;
}
if is_word_separator(&item) {
let word_spacing_px = c.style.word_spacing.resolve_px(c.style.font_size_px);
main_axis_pen += word_spacing_px;
main_axis_pen += extra_word_spacing;
}
}
}
}
}
(positioned, line_box_height)
}
fn calculate_alignment_offset(
items: &[ShapedItem],
line_constraints: &LineConstraints,
align: TextAlign,
is_vertical: bool,
constraints: &UnifiedConstraints,
) -> f32 {
if let Some(segment) = line_constraints.segments.first() {
let total_width: f32 = items
.iter()
.map(|item| get_item_measure_with_spacing(item, is_vertical))
.sum();
let available_width = if constraints.segment_alignment == SegmentAlignment::Total {
line_constraints.total_available
} else {
segment.width
};
if total_width >= available_width {
return 0.0; }
let remaining_space = available_width - total_width;
match align {
TextAlign::Center => remaining_space / 2.0,
TextAlign::Right => remaining_space,
_ => 0.0, }
} else {
0.0
}
}
#[allow(clippy::cast_precision_loss)] fn calculate_justification_spacing(
items: &[ShapedItem],
line_constraints: &LineConstraints,
text_justify: JustifyContent,
is_vertical: bool,
) -> (f32, f32) {
let total_width: f32 = items
.iter()
.map(|item| get_item_measure(item, is_vertical))
.sum();
let available_width = line_constraints.total_available;
if total_width >= available_width || available_width <= 0.0 {
return (0.0, 0.0);
}
let extra_space = available_width - total_width;
match text_justify {
JustifyContent::InterWord => {
let space_count = items.iter().filter(|item| is_word_separator(item)).count();
if space_count > 0 {
(extra_space / space_count as f32, 0.0)
} else {
(0.0, 0.0) }
}
JustifyContent::InterCharacter | JustifyContent::Distribute => {
let gap_count = items
.iter()
.enumerate()
.filter(|(i, item)| *i < items.len() - 1 && can_justify_after(item))
.count();
if gap_count > 0 {
(0.0, extra_space / gap_count as f32)
} else {
(0.0, 0.0) }
}
_ => (0.0, 0.0),
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] #[allow(clippy::too_many_lines)] pub fn justify_kashida_and_rebuild<T: ParsedFontTrait>(
items: Vec<ShapedItem>,
line_constraints: &LineConstraints,
is_vertical: bool,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
fonts: &LoadedFonts<T>,
) -> Vec<ShapedItem> {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"\n--- Entering justify_kashida_and_rebuild ---".to_string(),
));
}
let total_width: f32 = items
.iter()
.map(|item| get_item_measure(item, is_vertical))
.sum();
let available_width = line_constraints.total_available;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Total item width: {total_width}, Available width: {available_width}"
)));
}
if total_width >= available_width || available_width <= 0.0 {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"No justification needed (line is full or invalid).".to_string(),
));
}
return items;
}
let extra_space = available_width - total_width;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Extra space to fill: {extra_space}"
)));
}
let font_info = items.iter().find_map(|item| {
if let ShapedItem::Cluster(c) = item {
if let Some(glyph) = c.glyphs.first() {
if glyph.script == Script::Arabic {
if let Some(font) = fonts.get_by_hash(glyph.font_hash) {
return Some((
font.clone(),
glyph.font_hash,
glyph.font_metrics,
glyph.style.clone(),
));
}
}
}
}
None
});
let (font, font_hash, font_metrics, style) = if let Some(info) = font_info {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"Found Arabic font for kashida.".to_string(),
));
}
info
} else {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"No Arabic font found on line. Cannot insert kashidas.".to_string(),
));
}
return items;
};
let (kashida_glyph_id, kashida_advance) =
match font.get_kashida_glyph_and_advance(style.font_size_px) {
Some((id, adv)) if adv > 0.0 => {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Font provides kashida glyph with advance {adv}"
)));
}
(id, adv)
}
_ => {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"Font does not support kashida justification.".to_string(),
));
}
return items;
}
};
let opportunity_indices: Vec<usize> = items
.windows(2)
.enumerate()
.filter_map(|(i, window)| {
if let (ShapedItem::Cluster(cur), ShapedItem::Cluster(next)) = (&window[0], &window[1])
{
if is_arabic_cluster(cur)
&& is_arabic_cluster(next)
&& !is_word_separator(&window[1])
{
return Some(i + 1);
}
}
None
})
.collect();
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Found {} kashida insertion opportunities at indices: {:?}",
opportunity_indices.len(),
opportunity_indices
)));
}
if opportunity_indices.is_empty() {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(
"No opportunities found. Exiting.".to_string(),
));
}
return items;
}
let num_kashidas_to_insert = (extra_space / kashida_advance).floor() as usize;
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Calculated number of kashidas to insert: {num_kashidas_to_insert}"
)));
}
if num_kashidas_to_insert == 0 {
return items;
}
let kashidas_per_point = num_kashidas_to_insert / opportunity_indices.len();
let mut remainder = num_kashidas_to_insert % opportunity_indices.len();
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Distributing kashidas: {kashidas_per_point} per point, with {remainder} remainder."
)));
}
let kashida_item = {
let kashida_glyph = ShapedGlyph {
kind: GlyphKind::Kashida {
width: kashida_advance,
},
glyph_id: kashida_glyph_id,
font_hash,
font_metrics,
style: style.clone(),
script: Script::Arabic,
advance: kashida_advance,
kerning: 0.0,
cluster_offset: 0,
offset: Point::default(),
vertical_advance: 0.0,
vertical_offset: Point::default(),
};
ShapedItem::Cluster(ShapedCluster {
text: "\u{0640}".to_string(),
source_cluster_id: GraphemeClusterId {
source_run: u32::MAX,
start_byte_in_run: u32::MAX,
},
source_content_index: ContentIndex {
run_index: u32::MAX,
item_index: u32::MAX,
},
source_node_id: None, glyphs: smallvec![kashida_glyph],
advance: kashida_advance,
direction: BidiDirection::Ltr,
style,
marker_position_outside: None,
is_first_fragment: true,
is_last_fragment: true,
})
};
let mut new_items = Vec::with_capacity(items.len() + num_kashidas_to_insert);
let mut last_copy_idx = 0;
for &point in &opportunity_indices {
new_items.extend_from_slice(&items[last_copy_idx..point]);
let mut num_to_insert = kashidas_per_point;
if remainder > 0 {
num_to_insert += 1;
remainder -= 1;
}
for _ in 0..num_to_insert {
new_items.push(kashida_item.clone());
}
last_copy_idx = point;
}
new_items.extend_from_slice(&items[last_copy_idx..]);
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"--- Exiting justify_kashida_and_rebuild, new item count: {} ---",
new_items.len()
)));
}
new_items
}
fn is_arabic_cluster(cluster: &ShapedCluster) -> bool {
cluster.glyphs.iter().any(|g| g.script == Script::Arabic)
}
fn measure_trailing_whitespace(items: &[ShapedItem], is_vertical: bool) -> f32 {
let mut trailing_ws = 0.0;
for item in items.iter().rev() {
if is_collapsible_whitespace(item) {
trailing_ws += get_item_measure(item, is_vertical);
} else {
break;
}
}
trailing_ws
}
#[must_use] pub fn is_collapsible_whitespace(item: &ShapedItem) -> bool {
if let ShapedItem::Cluster(c) = item {
c.text.chars().all(|ch| matches!(ch,
' ' | '\t' | '\u{1680}' ))
} else {
false
}
}
pub fn is_cursive_script_cluster(c: &ShapedCluster) -> bool {
c.text.chars().next().is_some_and(is_cursive_script_char)
}
fn is_cursive_script_char(ch: char) -> bool {
let cp = ch as u32;
if (0x0600..=0x06FF).contains(&cp) { return true; }
if (0x0750..=0x077F).contains(&cp) { return true; }
if (0x08A0..=0x08FF).contains(&cp) { return true; }
if (0xFB50..=0xFDFF).contains(&cp) { return true; }
if (0xFE70..=0xFEFF).contains(&cp) { return true; }
if (0x0700..=0x074F).contains(&cp) { return true; }
if (0x1800..=0x18AF).contains(&cp) { return true; }
if (0x07C0..=0x07FF).contains(&cp) { return true; }
if (0x0840..=0x085F).contains(&cp) { return true; }
if (0xA840..=0xA87F).contains(&cp) { return true; }
if (0x10D00..=0x10D3F).contains(&cp) { return true; }
false
}
pub(crate) fn is_word_char(ch: char) -> bool {
ch.is_alphanumeric() || ch == '_'
}
fn cluster_is_word_boundary(cluster: &ShapedCluster) -> bool {
!cluster.text.chars().any(is_word_char)
}
pub fn is_word_separator(item: &ShapedItem) -> bool {
if let ShapedItem::Cluster(c) = item {
c.text.chars().any(is_word_separator_char)
} else {
false
}
}
#[must_use] pub fn is_no_break_space(item: &ShapedItem) -> bool {
if let ShapedItem::Cluster(c) = item {
c.text
.chars()
.any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
} else {
false
}
}
#[allow(clippy::match_same_arms)] const fn is_word_separator_char(c: char) -> bool {
match c {
'\u{0020}' => true,
'\u{00A0}' => true,
'\u{1680}' => true,
'\u{1361}' => true,
'\u{2000}'..='\u{200A}' => false,
'\u{202F}' => true,
'\u{205F}' => true,
'\u{3000}' => false,
'\u{10100}' => true,
'\u{10101}' => true,
'\u{1039F}' => true,
'\u{1091F}' => true,
_ => false,
}
}
#[must_use] pub fn is_zero_width_space(item: &ShapedItem) -> bool {
if let ShapedItem::Cluster(c) = item {
c.text.contains('\u{200B}')
} else {
false
}
}
fn can_justify_after(item: &ShapedItem) -> bool {
if let ShapedItem::Cluster(c) = item {
c.text.chars().last().is_some_and(|g| {
!g.is_whitespace() && classify_character(g as u32) != CharacterClass::Combining
})
} else {
false
}
}
#[allow(clippy::match_same_arms)] const fn classify_character(codepoint: u32) -> CharacterClass {
match codepoint {
0x0020 | 0x00A0 | 0x3000 => CharacterClass::Space,
0x0021..=0x002F | 0x003A..=0x0040 | 0x005B..=0x0060 | 0x007B..=0x007E => {
CharacterClass::Punctuation
}
0x4E00..=0x9FFF | 0x3400..=0x4DBF => CharacterClass::Ideograph,
0x0300..=0x036F | 0x1AB0..=0x1AFF => CharacterClass::Combining,
0x1800..=0x18AF => CharacterClass::Letter,
_ => CharacterClass::Letter,
}
}
#[must_use] pub fn get_item_measure(item: &ShapedItem, is_vertical: bool) -> f32 {
match item {
ShapedItem::Cluster(c) => {
let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
c.advance + total_kerning
}
ShapedItem::Object { bounds, .. }
| ShapedItem::CombinedBlock { bounds, .. }
| ShapedItem::Tab { bounds, .. } => {
if is_vertical {
bounds.height
} else {
bounds.width
}
}
ShapedItem::Break { .. } => 0.0,
}
}
#[must_use]
pub fn get_item_measure_with_spacing(item: &ShapedItem, is_vertical: bool) -> f32 {
let base = get_item_measure(item, is_vertical);
if let ShapedItem::Cluster(c) = item {
let mut extra = 0.0;
if !is_cursive_script_cluster(c) {
extra += c.style.letter_spacing.resolve_px(c.style.font_size_px);
}
if is_word_separator(item) {
extra += c.style.word_spacing.resolve_px(c.style.font_size_px);
}
base + extra
} else {
base
}
}
#[allow(clippy::match_same_arms)] fn get_line_constraints(
line_y: f32,
line_height: f32,
constraints: &UnifiedConstraints,
debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> LineConstraints {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"\n--- Entering get_line_constraints for y={line_y} ---"
)));
}
let mut available_segments = Vec::new();
if constraints.shape_boundaries.is_empty() {
let segment_width = match constraints.available_width {
AvailableSpace::Definite(w) => w, AvailableSpace::MaxContent => f32::MAX / 2.0, AvailableSpace::MinContent => f32::MAX / 2.0, };
available_segments.push(LineSegment {
start_x: 0.0,
width: segment_width,
priority: 0,
});
} else {
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Initial available segments: {available_segments:?}"
)));
}
for (idx, exclusion) in constraints.shape_exclusions.iter().enumerate() {
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Applying exclusion #{idx}: {exclusion:?}"
)));
}
let exclusion_spans =
get_shape_horizontal_spans(exclusion, line_y, line_height);
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" Exclusion spans at y={line_y}: {exclusion_spans:?}"
)));
}
if exclusion_spans.is_empty() {
continue;
}
let mut next_segments = Vec::new();
for (excl_start, excl_end) in exclusion_spans {
for segment in &available_segments {
let seg_start = segment.start_x;
let seg_end = segment.start_x + segment.width;
if seg_end > excl_start && seg_start < excl_end {
if seg_start < excl_start {
next_segments.push(LineSegment {
start_x: seg_start,
width: excl_start - seg_start,
priority: segment.priority,
});
}
if seg_end > excl_end {
next_segments.push(LineSegment {
start_x: excl_end,
width: seg_end - excl_end,
priority: segment.priority,
});
}
} else {
next_segments.push(*segment); }
}
available_segments = merge_segments(next_segments);
next_segments = Vec::new();
}
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
" Segments after exclusion #{idx}: {available_segments:?}"
)));
}
}
let total_width = available_segments.iter().map(|s| s.width).sum();
if let Some(msgs) = debug_messages {
msgs.push(LayoutDebugMessage::info(format!(
"Final segments: {available_segments:?}, total available width: {total_width}"
)));
msgs.push(LayoutDebugMessage::info(
"--- Exiting get_line_constraints ---".to_string(),
));
}
LineConstraints {
segments: available_segments,
total_available: total_width,
is_min_content: matches!(constraints.available_width, AvailableSpace::MinContent),
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
fn flatten_svg_to_path_segments(
multipolygon: &azul_core::svg::SvgMultiPolygon,
reference_box: Rect,
) -> Vec<PathSegment> {
use azul_core::svg::SvgPathElement;
let mut out: Vec<PathSegment> = Vec::new();
for ring in multipolygon.rings.as_ref() {
let elements = ring.items.as_ref();
if elements.is_empty() {
continue;
}
let start = elements[0].get_start();
out.push(PathSegment::MoveTo(Point {
x: reference_box.x + start.x,
y: reference_box.y + start.y,
}));
for el in elements {
match el {
SvgPathElement::Line(l) => {
out.push(PathSegment::LineTo(Point {
x: reference_box.x + l.end.x,
y: reference_box.y + l.end.y,
}));
}
curve => {
let len = curve.get_length();
let steps = ((len / 4.0).ceil() as usize).clamp(1, 64);
for i in 1..=steps {
let offset = len * (i as f64) / (steps as f64);
let t = curve.get_t_at_offset(offset);
out.push(PathSegment::LineTo(Point {
x: reference_box.x + curve.get_x_at_t(t) as f32,
y: reference_box.y + curve.get_y_at_t(t) as f32,
}));
}
}
}
}
out.push(PathSegment::Close);
}
out
}
fn path_segments_line_intersection(
segments: &[PathSegment],
y: f32,
line_height: f32,
) -> Vec<(f32, f32)> {
let line_center_y = y + line_height / 2.0;
let mut crossings: Vec<f32> = Vec::new();
let mut subpath: Vec<Point> = Vec::new();
let flush = |subpath: &mut Vec<Point>, crossings: &mut Vec<f32>| {
if subpath.len() >= 2 {
for i in 0..subpath.len() {
let p1 = subpath[i];
let p2 = subpath[(i + 1) % subpath.len()];
if (p2.y - p1.y).abs() < f32::EPSILON {
continue;
}
let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
|| (p1.y > line_center_y && p2.y <= line_center_y);
if crosses {
let t = (line_center_y - p1.y) / (p2.y - p1.y);
crossings.push(t.mul_add(p2.x - p1.x, p1.x));
}
}
}
subpath.clear();
};
for seg in segments {
match seg {
PathSegment::MoveTo(p) => {
flush(&mut subpath, &mut crossings);
subpath.push(*p);
}
PathSegment::LineTo(p) => subpath.push(*p),
PathSegment::Close => flush(&mut subpath, &mut crossings),
PathSegment::CurveTo { end, .. } | PathSegment::QuadTo { end, .. } => {
subpath.push(*end);
}
PathSegment::Arc { center, .. } => subpath.push(*center),
}
}
flush(&mut subpath, &mut crossings);
crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let mut spans = Vec::new();
for chunk in crossings.chunks_exact(2) {
if chunk[1] > chunk[0] {
spans.push((chunk[0], chunk[1]));
}
}
spans
}
#[allow(clippy::suboptimal_flops)] fn get_shape_horizontal_spans(
shape: &ShapeBoundary,
y: f32,
line_height: f32,
) -> Vec<(f32, f32)> {
match shape {
ShapeBoundary::Rectangle(rect) => {
let line_start = y;
let line_end = y + line_height;
let rect_start = rect.y;
let rect_end = rect.y + rect.height;
if line_start < rect_end && line_end > rect_start {
vec![(rect.x, rect.x + rect.width)]
} else {
vec![]
}
}
ShapeBoundary::Circle { center, radius } => {
let line_center_y = y + line_height / 2.0;
let dy = (line_center_y - center.y).abs();
if dy <= *radius {
let dx = (radius.powi(2) - dy.powi(2)).sqrt();
vec![(center.x - dx, center.x + dx)]
} else {
vec![]
}
}
ShapeBoundary::Ellipse { center, radii } => {
let line_center_y = y + line_height / 2.0;
let dy = line_center_y - center.y;
if dy.abs() <= radii.height {
let y_term = dy / radii.height;
let x_term_squared = 1.0 - y_term.powi(2);
if x_term_squared >= 0.0 {
let dx = radii.width * x_term_squared.sqrt();
vec![(center.x - dx, center.x + dx)]
} else {
vec![]
}
} else {
vec![]
}
}
ShapeBoundary::Polygon { points } => {
let segments = polygon_line_intersection(points, y, line_height);
segments
.iter()
.map(|s| (s.start_x, s.start_x + s.width))
.collect()
}
ShapeBoundary::Path { segments } => {
path_segments_line_intersection(segments, y, line_height)
}
}
}
fn merge_segments(mut segments: Vec<LineSegment>) -> Vec<LineSegment> {
if segments.len() <= 1 {
return segments;
}
segments.sort_by(|a, b| a.start_x.partial_cmp(&b.start_x).unwrap_or(Ordering::Equal));
let mut merged = vec![segments[0]];
for next_seg in segments.iter().skip(1) {
let last = merged.last_mut().unwrap();
if next_seg.start_x <= last.start_x + last.width {
let new_width = (next_seg.start_x + next_seg.width) - last.start_x;
last.width = last.width.max(new_width);
} else {
merged.push(*next_seg);
}
}
merged
}
#[allow(clippy::suboptimal_flops)] fn polygon_line_intersection(
points: &[Point],
y: f32,
line_height: f32,
) -> Vec<LineSegment> {
if points.len() < 3 {
return vec![];
}
let line_center_y = y + line_height / 2.0;
let mut intersections = Vec::new();
for i in 0..points.len() {
let p1 = points[i];
let p2 = points[(i + 1) % points.len()];
if (p2.y - p1.y).abs() < f32::EPSILON {
continue;
}
let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
|| (p1.y > line_center_y && p2.y <= line_center_y);
if crosses {
let t = (line_center_y - p1.y) / (p2.y - p1.y);
let x = p1.x + t * (p2.x - p1.x);
intersections.push(x);
}
}
intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
let mut segments = Vec::new();
for chunk in intersections.chunks_exact(2) {
let start_x = chunk[0];
let end_x = chunk[1];
if end_x > start_x {
segments.push(LineSegment {
start_x,
width: end_x - start_x,
priority: 0,
});
}
}
segments
}
#[cfg(feature = "text_layout_hyphenation")]
fn get_hyphenator(language: HyphenationLanguage) -> Result<Standard, LayoutError> {
Standard::from_embedded(language).map_err(|e| LayoutError::HyphenationError(e.to_string()))
}
#[cfg(not(feature = "text_layout_hyphenation"))]
fn get_hyphenator(_language: Language) -> Result<Standard, LayoutError> {
Err(LayoutError::HyphenationError("Hyphenation feature not enabled".to_string()))
}
const fn is_break_suppressing_control(ch: char) -> bool {
matches!(ch,
'\u{200D}' | '\u{2060}' | '\u{FEFF}' )
}
const fn is_break_forcing_control(ch: char) -> bool {
matches!(ch,
'\u{200B}' | '\u{2028}' | '\u{2029}' )
}
const fn is_cjk_character(ch: char) -> bool {
let cp = ch as u32;
matches!(cp,
0x4E00..=0x9FFF |
0x3400..=0x4DBF |
0x20000..=0x2A6DF |
0xF900..=0xFAFF |
0x3040..=0x309F |
0x30A0..=0x30FF |
0x31F0..=0x31FF |
0x3000..=0x303F |
0xFF00..=0xFFEF |
0xAC00..=0xD7AF
)
}
fn is_cjk_cluster(cluster: &ShapedCluster) -> bool {
cluster.text.chars().any(is_cjk_character)
}
fn is_break_opportunity_with_word_break(item: &ShapedItem, word_break: WordBreak, hyphens: Hyphens) -> bool {
if let ShapedItem::Cluster(c) = item {
if c.text
.chars()
.any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
{
return false;
}
}
if is_word_separator(item) {
return true;
}
if let ShapedItem::Break { .. } = item {
return true;
}
if is_zero_width_space(item) {
return true;
}
if hyphens != Hyphens::None {
if let ShapedItem::Cluster(c) = item {
if c.text.starts_with('\u{00AD}') {
return true;
}
}
}
if let ShapedItem::Cluster(c) = item {
if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') || c.text.ends_with('\u{002F}') {
return true;
}
}
match word_break {
WordBreak::Normal => {
if let ShapedItem::Cluster(c) = item {
if is_cjk_cluster(c) {
return true;
}
}
false
}
WordBreak::BreakAll => {
if let ShapedItem::Cluster(_) = item {
return true;
}
false
}
WordBreak::KeepAll => {
false
}
}
}
#[allow(clippy::match_same_arms)] const fn is_cjk_break_allowed_by_strictness(
ch: char,
_prev_ch: Option<char>,
strictness: LineBreakStrictness,
) -> bool {
match strictness {
LineBreakStrictness::Anywhere => true,
LineBreakStrictness::Loose => {
true
}
LineBreakStrictness::Normal | LineBreakStrictness::Auto => {
match ch {
'\u{2010}' | '\u{2013}' => false, _ => true,
}
}
LineBreakStrictness::Strict => {
match ch {
'\u{301C}' | '\u{30A0}' => false, '\u{2010}' | '\u{2013}' => false, c if is_small_kana(c) => false,
_ => true,
}
}
}
}
const fn is_small_kana(ch: char) -> bool {
matches!(ch,
'\u{3041}' | '\u{3043}' | '\u{3045}' | '\u{3047}' | '\u{3049}' | '\u{3063}' | '\u{3083}' | '\u{3085}' | '\u{3087}' | '\u{308E}' | '\u{3095}' | '\u{3096}' | '\u{30A1}' | '\u{30A3}' | '\u{30A5}' | '\u{30A7}' | '\u{30A9}' | '\u{30C3}' | '\u{30E3}' | '\u{30E5}' | '\u{30E7}' | '\u{30EE}' | '\u{30F5}' | '\u{30F6}' | '\u{30FC}' )
}
fn is_break_opportunity(item: &ShapedItem) -> bool {
if matches!(item, ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. }) {
return true;
}
if let ShapedItem::Cluster(c) = item {
if c.text.contains('\u{200B}') {
return true;
}
if c.text.chars().any(is_break_forcing_control) {
return true;
}
if c.text.chars().any(|ch| matches!(ch, '\u{2060}' | '\u{200D}' | '\u{00A0}')) {
return false;
}
if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') {
return true;
}
}
is_break_opportunity_with_word_break(item, WordBreak::Normal, Hyphens::Manual)
}
#[derive(Debug, Clone)]
pub struct BreakCursor<'a> {
pub items: &'a [ShapedItem],
pub next_item_index: usize,
pub partial_remainder: Vec<ShapedItem>,
pub word_break: WordBreak,
pub hyphens: Hyphens,
pub line_break: LineBreakStrictness,
}
impl<'a> BreakCursor<'a> {
#[must_use] pub fn new(items: &'a [ShapedItem]) -> Self {
Self {
items,
next_item_index: 0,
partial_remainder: Vec::new(),
word_break: WordBreak::Normal,
hyphens: Hyphens::default(),
line_break: LineBreakStrictness::default(),
}
}
#[must_use] pub fn with_word_break(items: &'a [ShapedItem], word_break: WordBreak) -> Self {
Self {
items,
next_item_index: 0,
partial_remainder: Vec::new(),
word_break,
hyphens: Hyphens::default(),
line_break: LineBreakStrictness::default(),
}
}
#[must_use] pub const fn is_at_start(&self) -> bool {
self.next_item_index == 0 && self.partial_remainder.is_empty()
}
pub fn drain_remaining(&mut self) -> Vec<ShapedItem> {
let mut remaining = std::mem::take(&mut self.partial_remainder);
if self.next_item_index < self.items.len() {
remaining.extend_from_slice(&self.items[self.next_item_index..]);
}
self.next_item_index = self.items.len();
remaining
}
#[must_use] pub const fn is_done(&self) -> bool {
self.next_item_index >= self.items.len() && self.partial_remainder.is_empty()
}
pub fn consume(&mut self, count: usize) {
if count == 0 {
return;
}
let remainder_len = self.partial_remainder.len();
if count <= remainder_len {
self.partial_remainder.drain(..count);
} else {
let from_main_list = count - remainder_len;
self.partial_remainder.clear();
self.next_item_index += from_main_list;
}
}
pub fn peek_next_unit(&self) -> Vec<ShapedItem> {
let mut unit = Vec::new();
let mut source_items = self.partial_remainder.clone();
source_items.extend_from_slice(&self.items[self.next_item_index..]);
if source_items.is_empty() {
return unit;
}
if is_break_opportunity_with_word_break(&source_items[0], self.word_break, self.hyphens) {
unit.push(source_items[0].clone());
return unit;
}
let mut suppress_next_break = false;
for (i, item) in source_items.iter().enumerate() {
let starts_with_suppress = if let ShapedItem::Cluster(c) = item {
c.text.chars().next().is_some_and(is_break_suppressing_control)
} else {
false
};
let cjk_strictness_suppressed = if let ShapedItem::Cluster(c) = item {
c.text.chars().next().is_some_and(|ch| {
!is_cjk_break_allowed_by_strictness(ch, None, self.line_break)
})
} else {
false
};
if i > 0 && !suppress_next_break && !starts_with_suppress && !cjk_strictness_suppressed && is_break_opportunity_with_word_break(item, self.word_break, self.hyphens) {
break;
}
suppress_next_break = false;
unit.push(item.clone());
if let ShapedItem::Cluster(c) = item {
if let Some(last_ch) = c.text.chars().last() {
if is_break_suppressing_control(last_ch) {
suppress_next_break = true;
}
}
}
if self.word_break == WordBreak::BreakAll {
if let ShapedItem::Cluster(_) = item {
break;
}
}
}
unit
}
#[must_use] pub fn peek_next_single_item(&self) -> Vec<ShapedItem> {
if !self.partial_remainder.is_empty() {
return vec![self.partial_remainder[0].clone()];
}
if self.next_item_index < self.items.len() {
return vec![self.items[self.next_item_index].clone()];
}
Vec::new()
}
}
struct HyphenationResult {
line_part: Vec<ShapedItem>,
remainder_part: Vec<ShapedItem>,
}
fn perform_bidi_analysis<'a>(
styled_runs: &'a [TextRunInfo<'_>],
full_text: &'a str,
force_lang: Option<Language>,
) -> (Vec<VisualRun<'a>>, BidiDirection) {
if full_text.is_empty() {
return (Vec::new(), BidiDirection::Ltr);
}
let bidi_info = BidiInfo::new(full_text, None);
let para = &bidi_info.paragraphs[0];
let base_direction = if para.level.is_rtl() {
BidiDirection::Rtl
} else {
BidiDirection::Ltr
};
let mut byte_to_run_index: Vec<usize> = vec![0; full_text.len()];
for (run_idx, run) in styled_runs.iter().enumerate() {
let start = run.logical_start;
let end = start + run.text.len();
for slot in &mut byte_to_run_index[start..end] {
*slot = run_idx;
}
}
let mut final_visual_runs = Vec::new();
let (levels, visual_run_ranges) = bidi_info.visual_runs(para, para.range.clone());
for range in visual_run_ranges {
let bidi_level = levels[range.start];
let mut sub_run_start = range.start;
for i in (range.start + 1)..range.end {
if byte_to_run_index[i] != byte_to_run_index[sub_run_start] {
let original_run_idx = byte_to_run_index[sub_run_start];
let script = crate::text3::script::detect_script(&full_text[sub_run_start..i])
.unwrap_or(Script::Latin);
final_visual_runs.push(VisualRun {
text_slice: &full_text[sub_run_start..i],
style: styled_runs[original_run_idx].style.clone(),
logical_start_byte: sub_run_start,
bidi_level: BidiLevel::new(bidi_level.number()),
language: force_lang.unwrap_or_else(|| {
script_to_language(
script,
&full_text[sub_run_start..i],
)
}),
script,
});
sub_run_start = i;
}
}
let original_run_idx = byte_to_run_index[sub_run_start];
let script = crate::text3::script::detect_script(&full_text[sub_run_start..range.end])
.unwrap_or(Script::Latin);
final_visual_runs.push(VisualRun {
text_slice: &full_text[sub_run_start..range.end],
style: styled_runs[original_run_idx].style.clone(),
logical_start_byte: sub_run_start,
bidi_level: BidiLevel::new(bidi_level.number()),
script,
language: force_lang.unwrap_or_else(|| {
script_to_language(
script,
&full_text[sub_run_start..range.end],
)
}),
});
}
(final_visual_runs, base_direction)
}
const fn get_justification_priority(class: CharacterClass) -> u8 {
match class {
CharacterClass::Space => 0,
CharacterClass::Punctuation => 64,
CharacterClass::Ideograph => 128,
CharacterClass::Letter => 192,
CharacterClass::Symbol => 224,
CharacterClass::Combining => 255,
}
}
#[cfg(test)]
mod shape_outside_and_ruby_tests {
use super::*;
use azul_css::shape::{CssShape, ShapePath};
fn path_shape(d: &str) -> CssShape {
CssShape::Path(ShapePath {
data: d.into(),
})
}
#[test]
fn css_path_shape_builds_path_boundary_not_rect_fallback() {
let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
match boundary {
ShapeBoundary::Path { segments } => {
assert!(!segments.is_empty(), "path() must flatten to real segments");
assert!(matches!(segments[0], PathSegment::MoveTo(_)));
assert!(segments.iter().any(|s| matches!(s, PathSegment::Close)));
}
other => panic!("expected ShapeBoundary::Path, got {other:?}"),
}
}
#[test]
fn empty_or_garbage_path_falls_back_to_rectangle() {
let rbox = Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 };
let boundary = ShapeBoundary::from_css_shape(&path_shape(" "), rbox, &mut None);
assert!(matches!(boundary, ShapeBoundary::Rectangle(_)),
"unparseable path() should fall back to the reference rectangle");
}
#[test]
fn path_triangle_narrows_line_box_per_scanline() {
let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
let spans_top = get_shape_horizontal_spans(&boundary, 10.0, 1.0);
let spans_bot = get_shape_horizontal_spans(&boundary, 80.0, 1.0);
assert_eq!(spans_top.len(), 1, "single span expected near the top");
assert_eq!(spans_bot.len(), 1, "single span expected near the bottom");
let width_top = spans_top[0].1 - spans_top[0].0;
let width_bot = spans_bot[0].1 - spans_bot[0].0;
assert!((width_top - 89.5).abs() < 1.5, "top width {width_top} != ~89.5");
assert!((width_bot - 19.5).abs() < 1.5, "bottom width {width_bot} != ~19.5");
assert!(width_top > width_bot,
"path() exclusion band must narrow with y ({width_top} !> {width_bot})");
assert!(width_bot < 50.0, "rect fallback would give full width here");
}
#[test]
fn path_with_hole_carves_out_interior_via_even_odd() {
let shape = path_shape(
"M 0 0 L 100 0 L 100 100 L 0 100 Z \
M 30 30 L 30 70 L 70 70 L 70 30 Z",
);
let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
let spans = get_shape_horizontal_spans(&boundary, 50.0, 1.0);
assert_eq!(spans.len(), 2, "hole should split the band into two spans: {spans:?}");
}
#[test]
#[allow(clippy::float_cmp)] fn ruby_annotation_font_scale_is_real_not_06_fudge() {
let base_font_size = 20.0_f32;
let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
assert_eq!(annotation_font_size, 10.0);
assert!((RUBY_ANNOTATION_FONT_SCALE - 0.6).abs() > f32::EPSILON,
"annotation scale must not be the old 0.6 magic ratio");
}
#[test]
#[allow(clippy::float_cmp)] fn ruby_box_reserves_max_width_and_stacks_annotation_above_base() {
let (w, h) = ruby_reserved_box(80.0, 30.0, 24.0, 12.0);
assert_eq!(w, 80.0, "reserved width is the wider of base/annotation");
assert_eq!(h, 36.0, "block-size = base line + annotation line");
assert!(h > 24.0, "ruby box must reserve extra vertical space for the annotation");
let (w2, _) = ruby_reserved_box(20.0, 50.0, 24.0, 12.0);
assert_eq!(w2, 50.0, "a long annotation widens the reserved box");
}
}
#[cfg(test)]
#[allow(
clippy::float_cmp,
clippy::too_many_lines,
clippy::unreadable_literal,
clippy::cast_precision_loss,
clippy::similar_names
)]
mod autotest_generated {
use super::*;
fn metrics(upem: u16, ascent: f32, descent: f32, line_gap: f32) -> LayoutFontMetrics {
LayoutFontMetrics {
ascent,
descent,
line_gap,
units_per_em: upem,
x_height: None,
cap_height: None,
}
}
fn std_metrics() -> LayoutFontMetrics {
metrics(1000, 800.0, -200.0, 0.0)
}
fn style() -> Arc<StyleProperties> {
Arc::new(StyleProperties::default())
}
fn styled(f: impl FnOnce(&mut StyleProperties)) -> Arc<StyleProperties> {
let mut s = StyleProperties::default();
f(&mut s);
Arc::new(s)
}
const fn ci(run: u32, item: u32) -> ContentIndex {
ContentIndex {
run_index: run,
item_index: item,
}
}
const fn gid(run: u32, byte: u32) -> GraphemeClusterId {
GraphemeClusterId {
source_run: run,
start_byte_in_run: byte,
}
}
fn shaped_glyph(st: Arc<StyleProperties>, fm: LayoutFontMetrics, advance: f32) -> ShapedGlyph {
ShapedGlyph {
kind: GlyphKind::Character,
glyph_id: 42,
cluster_offset: 0,
advance,
kerning: 0.0,
offset: Point { x: 0.0, y: 0.0 },
vertical_advance: advance,
vertical_offset: Point { x: 0.0, y: 0.0 },
script: Script::Latin,
style: st,
font_hash: 0xABCD_u64,
font_metrics: fm,
}
}
fn make_cluster(
text: &str,
advance: f32,
st: Arc<StyleProperties>,
glyphs: ShapedGlyphVec,
id: GraphemeClusterId,
) -> ShapedItem {
ShapedItem::Cluster(ShapedCluster {
text: text.to_string(),
source_cluster_id: id,
source_content_index: ci(id.source_run, id.start_byte_in_run),
source_node_id: None,
glyphs,
advance,
direction: BidiDirection::Ltr,
style: st,
marker_position_outside: None,
is_first_fragment: true,
is_last_fragment: true,
})
}
fn cl(text: &str, advance: f32) -> ShapedItem {
let st = style();
let g = shaped_glyph(st.clone(), std_metrics(), advance);
make_cluster(text, advance, st, smallvec![g], gid(0, 0))
}
fn cl_at(text: &str, advance: f32, run: u32, byte: u32) -> ShapedItem {
let st = style();
let g = shaped_glyph(st.clone(), std_metrics(), advance);
make_cluster(text, advance, st, smallvec![g], gid(run, byte))
}
fn cl_styled(text: &str, advance: f32, st: Arc<StyleProperties>) -> ShapedItem {
let g = shaped_glyph(st.clone(), std_metrics(), advance);
make_cluster(text, advance, st, smallvec![g], gid(0, 0))
}
fn cl_no_glyphs(text: &str, advance: f32) -> ShapedItem {
make_cluster(text, advance, style(), ShapedGlyphVec::new(), gid(0, 0))
}
fn obj(width: f32, height: f32, baseline_offset: f32) -> ShapedItem {
ShapedItem::Object {
source: ci(0, 0),
bounds: Rect {
x: 0.0,
y: 0.0,
width,
height,
},
baseline_offset,
content: InlineContent::Space(InlineSpace {
width,
is_breaking: false,
is_stretchy: false,
}),
}
}
fn brk() -> ShapedItem {
ShapedItem::Break {
source: ci(0, 0),
break_info: InlineBreak {
break_type: BreakType::Hard,
clear: ClearType::None,
content_index: 0,
},
}
}
fn tab(width: f32, height: f32) -> ShapedItem {
ShapedItem::Tab {
source: ci(0, 0),
bounds: Rect {
x: 0.0,
y: 0.0,
width,
height,
},
}
}
fn pos(item: ShapedItem, x: f32, y: f32, line_index: usize) -> PositionedItem {
PositionedItem {
item,
position: Point { x, y },
line_index,
}
}
fn text_content(t: &str, st: Arc<StyleProperties>) -> InlineContent {
InlineContent::Text(StyledRun {
text: t.to_string(),
style: st,
logical_start_byte: 0,
source_node_id: None,
})
}
fn sel(family: &str) -> FontSelector {
FontSelector {
family: family.to_string(),
..FontSelector::default()
}
}
#[derive(Debug, Clone)]
struct TestFont {
hash: u64,
}
impl ShallowClone for TestFont {
fn shallow_clone(&self) -> Self {
self.clone()
}
}
impl ParsedFontTrait for TestFont {
fn shape_text(
&self,
_text: &str,
_script: Script,
_language: Language,
_direction: BidiDirection,
_style: &StyleProperties,
) -> Result<Vec<Glyph>, LayoutError> {
Ok(Vec::new())
}
fn get_hash(&self) -> u64 {
self.hash
}
fn get_glyph_size(&self, _glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
Some(LogicalSize {
width: font_size,
height: font_size,
})
}
fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
Some((1, font_size * 0.3))
}
fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
Some((2, font_size * 0.2))
}
fn has_glyph(&self, _codepoint: u32) -> bool {
true
}
fn get_vertical_metrics(&self, _glyph_id: u16) -> Option<VerticalMetrics> {
None
}
fn get_font_metrics(&self) -> LayoutFontMetrics {
std_metrics()
}
fn num_glyphs(&self) -> u16 {
10
}
fn get_space_width(&self) -> Option<usize> {
Some(500)
}
}
fn hash_of<T: Hash>(v: &T) -> u64 {
let mut h = DefaultHasher::new();
v.hash(&mut h);
h.finish()
}
#[track_caller]
fn approx(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 1e-4,
"expected ~{expected}, got {actual}"
);
}
#[test]
fn ruby_reserved_box_zero_and_negative_are_deterministic() {
assert_eq!(ruby_reserved_box(0.0, 0.0, 0.0, 0.0), (0.0, 0.0));
let (w, h) = ruby_reserved_box(-10.0, -4.0, -3.0, -2.0);
assert_eq!(w, -4.0);
assert_eq!(h, -5.0);
}
#[test]
fn ruby_reserved_box_nan_width_is_ignored_by_max_but_poisons_height() {
let (w, h) = ruby_reserved_box(f32::NAN, 30.0, 24.0, f32::NAN);
assert_eq!(w, 30.0, "f32::max discards the NaN operand");
assert!(h.is_nan(), "but the additive block-size does propagate NaN");
}
#[test]
fn ruby_reserved_box_infinities_do_not_panic() {
let (w, h) = ruby_reserved_box(f32::INFINITY, 10.0, f32::INFINITY, f32::NEG_INFINITY);
assert!(w.is_infinite() && w.is_sign_positive());
assert!(h.is_nan(), "inf + -inf is NaN, not a panic");
}
#[test]
fn ruby_reserved_box_saturates_to_infinity_at_f32_max() {
let (w, h) = ruby_reserved_box(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
assert_eq!(w, f32::MAX);
assert!(h.is_infinite(), "f32 addition saturates, it does not panic");
}
#[test]
fn line_height_px_ignores_every_font_metric() {
let lh = LineHeight::Px(7.5);
assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 1000), 7.5);
assert_eq!(lh.resolve(f32::NAN, f32::NAN, f32::NAN, f32::NAN, 0), 7.5);
}
#[test]
fn line_height_normal_zero_upem_falls_back_to_1_2_em() {
let lh = LineHeight::Normal;
assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 0), 19.2);
assert_eq!(lh.resolve(0.0, 800.0, -200.0, 0.0, 0), 0.0);
}
#[test]
fn line_height_normal_scales_ascent_minus_descent_plus_gap() {
approx(LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, 1000), 16.0);
approx(
LineHeight::Normal.resolve(16.0, 800.0, -200.0, 250.0, 1000),
20.0,
);
approx(LineHeight::Normal.resolve(16.0, 800.0, 200.0, 0.0, 1000), 9.6);
}
#[test]
fn line_height_normal_at_u16_max_upem_does_not_panic() {
let v = LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, u16::MAX);
assert!(v.is_finite() && v > 0.0, "got {v}");
assert!(v < 1.0, "a 65535-upem font must produce a tiny scale, got {v}");
}
#[test]
fn line_height_normal_nan_and_inf_inputs_are_defined_not_panics() {
assert!(LineHeight::Normal
.resolve(f32::NAN, 800.0, -200.0, 0.0, 1000)
.is_nan());
assert!(LineHeight::Normal
.resolve(f32::INFINITY, 800.0, -200.0, 0.0, 1000)
.is_infinite());
assert!(LineHeight::Normal
.resolve(16.0, f32::INFINITY, f32::INFINITY, 0.0, 1000)
.is_nan());
}
#[test]
fn line_height_resolve_with_metrics_matches_resolve() {
let fm = metrics(2048, 1600.0, -400.0, 100.0);
let lh = LineHeight::Normal;
assert_eq!(
lh.resolve_with_metrics(24.0, &fm),
lh.resolve(24.0, fm.ascent, fm.descent, fm.line_gap, fm.units_per_em)
);
assert_eq!(LineHeight::Px(3.0).resolve_with_metrics(24.0, &fm), 3.0);
}
#[test]
fn line_height_px_nan_is_self_equal_under_the_manual_partialeq() {
assert_eq!(LineHeight::Px(f32::NAN), LineHeight::Px(f32::NAN));
assert_eq!(
hash_of(&LineHeight::Px(f32::NAN)),
hash_of(&LineHeight::Px(f32::NAN))
);
assert_ne!(LineHeight::Normal, LineHeight::Px(0.0));
}
#[test]
fn available_space_definite_and_indefinite_are_exact_complements() {
for v in [
AvailableSpace::Definite(0.0),
AvailableSpace::Definite(-1.0),
AvailableSpace::Definite(f32::NAN),
AvailableSpace::MinContent,
AvailableSpace::MaxContent,
] {
assert_ne!(v.is_definite(), v.is_indefinite(), "{v:?}");
}
assert!(AvailableSpace::Definite(f32::NAN).is_definite());
assert!(AvailableSpace::default().is_indefinite());
assert_eq!(AvailableSpace::default(), AvailableSpace::MaxContent);
}
#[test]
fn available_space_unwrap_or_returns_definite_even_when_nan_or_inf() {
assert_eq!(AvailableSpace::Definite(0.0).unwrap_or(99.0), 0.0);
assert_eq!(AvailableSpace::Definite(-5.0).unwrap_or(99.0), -5.0);
assert!(AvailableSpace::Definite(f32::NAN).unwrap_or(99.0).is_nan());
assert!(AvailableSpace::Definite(f32::INFINITY)
.unwrap_or(99.0)
.is_infinite());
assert_eq!(AvailableSpace::MinContent.unwrap_or(99.0), 99.0);
assert_eq!(AvailableSpace::MaxContent.unwrap_or(-0.0), -0.0);
assert!(AvailableSpace::MaxContent.unwrap_or(f32::NAN).is_nan());
}
#[test]
fn available_space_to_f32_for_layout_uses_half_max_for_both_intrinsic_modes() {
assert_eq!(AvailableSpace::MinContent.to_f32_for_layout(), f32::MAX / 2.0);
assert_eq!(AvailableSpace::MaxContent.to_f32_for_layout(), f32::MAX / 2.0);
assert_eq!(AvailableSpace::Definite(12.5).to_f32_for_layout(), 12.5);
assert!(AvailableSpace::Definite(f32::NAN)
.to_f32_for_layout()
.is_nan());
}
#[test]
fn available_space_from_f32_sentinels() {
assert_eq!(AvailableSpace::from_f32(f32::INFINITY), AvailableSpace::MaxContent);
assert_eq!(AvailableSpace::from_f32(f32::MAX), AvailableSpace::MaxContent);
assert_eq!(
AvailableSpace::from_f32(f32::MAX / 2.0),
AvailableSpace::MaxContent
);
assert_eq!(AvailableSpace::from_f32(0.0), AvailableSpace::MinContent);
assert_eq!(AvailableSpace::from_f32(-0.0), AvailableSpace::MinContent);
assert_eq!(AvailableSpace::from_f32(-1.0), AvailableSpace::MinContent);
assert_eq!(AvailableSpace::from_f32(100.0), AvailableSpace::Definite(100.0));
}
#[test]
fn available_space_from_f32_negative_infinity_becomes_max_content() {
assert_eq!(
AvailableSpace::from_f32(f32::NEG_INFINITY),
AvailableSpace::MaxContent
);
}
#[test]
fn available_space_from_f32_nan_falls_through_to_definite_nan() {
let got = AvailableSpace::from_f32(f32::NAN);
match got {
AvailableSpace::Definite(v) => assert!(v.is_nan(), "expected Definite(NaN)"),
other => panic!("NaN should fall through to Definite, got {other:?}"),
}
assert_ne!(got, AvailableSpace::from_f32(f32::NAN));
}
#[test]
fn available_space_hash_eq_contract_holds_for_signed_zero() {
assert_eq!(
AvailableSpace::Definite(0.0),
AvailableSpace::Definite(-0.0)
);
assert_eq!(
hash_of(&AvailableSpace::Definite(0.0)),
hash_of(&AvailableSpace::Definite(-0.0))
);
assert_ne!(
hash_of(&AvailableSpace::Definite(100.1)),
hash_of(&AvailableSpace::Definite(100.4))
);
assert_ne!(
hash_of(&AvailableSpace::MinContent),
hash_of(&AvailableSpace::MaxContent)
);
}
#[test]
fn font_chain_key_from_empty_selectors_defaults_to_serif() {
let k = FontChainKey::from_selectors(&[]);
assert_eq!(k.font_families, vec!["serif".to_string()]);
assert_eq!(k.weight, FcWeight::Normal);
assert!(!k.italic && !k.oblique);
}
#[test]
fn font_chain_key_dedups_first_wins_and_skips_empty_families() {
let stack = [sel("Arial"), sel("Times"), sel("Arial"), sel("")];
let k = FontChainKey::from_selectors(&stack);
assert_eq!(
k.font_families,
vec!["Arial".to_string(), "Times".to_string()],
"duplicate families must collapse first-wins, empty names dropped"
);
}
#[test]
fn font_chain_key_all_empty_families_still_yields_serif() {
let stack = [sel(""), sel(""), sel("")];
let k = FontChainKey::from_selectors(&stack);
assert_eq!(k.font_families, vec!["serif".to_string()]);
}
#[test]
fn font_chain_key_weight_and_style_come_from_the_first_selector_even_if_it_is_dropped() {
let mut first = sel("");
first.style = FontStyle::Italic;
first.weight = FcWeight::Bold;
let stack = [first, sel("Arial")];
let k = FontChainKey::from_selectors(&stack);
assert_eq!(k.font_families, vec!["Arial".to_string()]);
assert_eq!(k.weight, FcWeight::Bold);
assert!(k.italic, "italic taken from the dropped first selector");
assert!(!k.oblique);
}
#[test]
fn font_chain_key_oblique_is_exclusive_of_italic() {
let mut s = sel("Arial");
s.style = FontStyle::Oblique;
let k = FontChainKey::from_selectors(&[s]);
assert!(k.oblique && !k.italic);
}
#[test]
fn font_chain_key_huge_duplicate_stack_does_not_hang() {
let stack: Vec<FontSelector> = (0..5000).map(|_| sel("Arial")).collect();
let k = FontChainKey::from_selectors(&stack);
assert_eq!(k.font_families.len(), 1, "5000 dupes collapse to one entry");
}
#[test]
fn font_chain_key_is_a_stable_hash_map_key() {
let a = FontChainKey::from_selectors(&[sel("Arial"), sel("Times")]);
let b = FontChainKey::from_selectors(&[sel("Arial"), sel("Arial"), sel("Times")]);
assert_eq!(a, b, "dedup makes the two stacks resolve to the same key");
assert_eq!(hash_of(&a), hash_of(&b));
}
#[test]
fn font_chain_key_or_ref_from_stack_is_a_chain() {
let fs = FontStack::Stack(vec![sel("Arial")]);
let k = FontChainKeyOrRef::from_font_stack(&fs);
assert!(!k.is_ref());
assert_eq!(k.as_ref_ptr(), None);
assert_eq!(
k.as_chain().map(|c| c.font_families.clone()),
Some(vec!["Arial".to_string()])
);
}
#[test]
fn font_chain_key_or_ref_ref_variant_accessors_at_boundaries() {
for ptr in [0_usize, 1, usize::MAX] {
let k = FontChainKeyOrRef::Ref(ptr);
assert!(k.is_ref());
assert_eq!(k.as_ref_ptr(), Some(ptr));
assert!(k.as_chain().is_none());
}
assert_ne!(
FontChainKeyOrRef::Ref(0),
FontChainKeyOrRef::Chain(FontChainKey::from_selectors(&[]))
);
}
#[test]
fn font_stack_default_is_a_single_serif_selector() {
let fs = FontStack::default();
assert!(!fs.is_ref());
assert!(fs.as_ref().is_none());
assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(1));
assert_eq!(fs.first_selector().map(|s| s.family.as_str()), Some("serif"));
assert_eq!(fs.first_family(), "serif");
}
#[test]
fn font_stack_empty_stack_reports_serif_placeholder_but_no_first_selector() {
let fs = FontStack::Stack(Vec::new());
assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(0));
assert!(fs.first_selector().is_none());
assert_eq!(
fs.first_family(),
"serif",
"an EMPTY stack must not panic; it reports the serif fallback"
);
}
#[test]
fn font_hash_invalid_is_zero_and_is_the_default() {
assert_eq!(FontHash::invalid().font_hash, 0);
assert_eq!(FontHash::default(), FontHash::invalid());
assert_eq!(FontHash::from_hash(0), FontHash::invalid());
assert_eq!(FontHash::from_hash(u64::MAX).font_hash, u64::MAX);
assert_ne!(FontHash::from_hash(u64::MAX), FontHash::invalid());
}
#[test]
fn layout_font_metrics_baseline_scaled_typical_and_zero_font_size() {
let fm = std_metrics();
approx(fm.baseline_scaled(16.0), 12.8); assert_eq!(fm.baseline_scaled(0.0), 0.0);
approx(fm.baseline_scaled(-16.0), -12.8);
}
#[test]
fn layout_font_metrics_zero_upem_divides_by_zero_instead_of_guarding() {
let fm = metrics(0, 800.0, -200.0, 0.0);
assert!(fm.baseline_scaled(16.0).is_infinite());
assert!(fm.cap_height_scaled(16.0).is_infinite());
let zero = metrics(0, 0.0, 0.0, 0.0);
assert!(zero.baseline_scaled(16.0).is_nan());
}
#[test]
fn layout_font_metrics_x_height_falls_back_to_half_em() {
let fm = std_metrics(); assert_eq!(fm.x_height_scaled(16.0), 8.0, "fallback is 0.5em");
assert_eq!(fm.x_height_scaled(0.0), 0.0);
let mut with_xh = std_metrics();
with_xh.x_height = Some(500.0);
approx(with_xh.x_height_scaled(16.0), 8.0);
with_xh.x_height = Some(0.0);
assert_eq!(
with_xh.x_height_scaled(16.0),
0.0,
"an explicit sxHeight of 0 must NOT re-trigger the 0.5em fallback"
);
}
#[test]
fn layout_font_metrics_cap_height_falls_back_to_ascent() {
let fm = std_metrics(); assert_eq!(fm.cap_height_scaled(16.0), fm.baseline_scaled(16.0));
let mut with_cap = std_metrics();
with_cap.cap_height = Some(700.0);
approx(with_cap.cap_height_scaled(16.0), 11.2);
}
#[test]
fn layout_font_metrics_nan_font_size_propagates_without_panicking() {
let fm = std_metrics();
assert!(fm.baseline_scaled(f32::NAN).is_nan());
assert!(fm.x_height_scaled(f32::NAN).is_nan());
assert!(fm.cap_height_scaled(f32::NAN).is_nan());
assert!(fm.baseline_scaled(f32::INFINITY).is_infinite());
}
#[test]
fn layout_font_metrics_synthesized_baselines_span_exactly_one_em() {
let fm = std_metrics();
assert_eq!(fm.central_baseline(), 300.0); assert_eq!(fm.em_over(), 800.0); assert_eq!(fm.em_under(), -200.0); assert_eq!(
fm.em_over() - fm.em_under(),
f32::from(fm.units_per_em),
"em-over minus em-under is by definition 1em"
);
}
#[test]
fn layout_font_metrics_baselines_at_u16_max_upem_and_zero_upem() {
let big = metrics(u16::MAX, 0.0, 0.0, 0.0);
assert_eq!(big.central_baseline(), 0.0);
assert_eq!(big.em_over(), f32::from(u16::MAX) / 2.0);
assert_eq!(big.em_under(), -f32::from(u16::MAX) / 2.0);
let zero = metrics(0, 100.0, -50.0, 0.0);
assert_eq!(zero.em_over(), zero.central_baseline());
assert_eq!(zero.em_under(), zero.central_baseline());
}
#[test]
fn layout_font_metrics_central_baseline_with_infinite_extents_is_nan() {
let fm = metrics(1000, f32::INFINITY, f32::NEG_INFINITY, 0.0);
assert!(fm.central_baseline().is_nan(), "midpoint(inf, -inf) is NaN");
assert!(fm.em_over().is_nan());
}
#[test]
fn round_eq_rounds_half_away_from_zero() {
assert!(round_eq(0.4, -0.4), "both round to 0");
assert!(round_eq(1.5, 2.4), "1.5 rounds away from zero to 2");
assert!(!round_eq(1.4, 1.5));
assert!(round_eq(-1.5, -2.0));
}
#[test]
fn round_eq_treats_nan_as_equal_to_everything_rounding_to_zero() {
assert!(round_eq(f32::NAN, f32::NAN));
assert!(round_eq(f32::NAN, 0.0));
assert!(round_eq(f32::NAN, 0.49));
assert!(!round_eq(f32::NAN, 1.0));
assert_eq!(
Rect {
x: f32::NAN,
y: 0.0,
width: 0.0,
height: 0.0
},
Rect::default(),
"a NaN-x Rect compares equal to the zero Rect"
);
}
#[test]
fn round_eq_saturates_infinity_and_f32_max_to_the_same_isize() {
assert!(round_eq(f32::INFINITY, f32::MAX));
assert!(round_eq(f32::NEG_INFINITY, f32::MIN));
assert!(!round_eq(f32::INFINITY, f32::NEG_INFINITY));
assert_eq!(
Size::new(f32::INFINITY, 0.0),
Size::new(f32::MAX, 0.0),
"saturating cast collapses inf and f32::MAX into one bucket"
);
}
#[test]
fn size_zero_is_the_neutral_element_and_new_preserves_bits() {
assert_eq!(Size::zero(), Size::new(0.0, 0.0));
assert_eq!(Size::zero().width, 0.0);
assert_eq!(Size::zero(), Size::default());
let weird = Size::new(f32::NAN, f32::INFINITY);
assert!(weird.width.is_nan(), "the constructor must not sanitize");
assert!(weird.height.is_infinite());
}
#[test]
fn bounding_box_of_empty_and_single_point_is_zero() {
assert_eq!(calculate_bounding_box_size(&[]), Size::zero());
assert_eq!(
calculate_bounding_box_size(&[Point { x: 5.0, y: -5.0 }]),
Size::zero()
);
}
#[test]
fn bounding_box_spans_negative_coordinates() {
let pts = [
Point { x: -10.0, y: -20.0 },
Point { x: 30.0, y: 5.0 },
Point { x: 0.0, y: 0.0 },
];
assert_eq!(calculate_bounding_box_size(&pts), Size::new(40.0, 25.0));
}
#[test]
fn bounding_box_of_all_nan_points_collapses_to_zero() {
let pts = [Point {
x: f32::NAN,
y: f32::NAN,
}];
assert_eq!(calculate_bounding_box_size(&pts), Size::zero());
}
#[test]
fn bounding_box_of_extreme_points_overflows_to_infinity_without_panicking() {
let pts = [
Point {
x: f32::MIN,
y: f32::MIN,
},
Point {
x: f32::MAX,
y: f32::MAX,
},
];
let s = calculate_bounding_box_size(&pts);
assert!(s.width.is_infinite() && s.height.is_infinite());
}
#[test]
fn shape_definition_get_size_for_each_variant() {
assert_eq!(
ShapeDefinition::Rectangle {
size: Size::new(3.0, 4.0),
corner_radius: None
}
.get_size(),
Size::new(3.0, 4.0)
);
assert_eq!(
ShapeDefinition::Circle { radius: 10.0 }.get_size(),
Size::new(20.0, 20.0)
);
assert_eq!(
ShapeDefinition::Ellipse {
radii: Size::new(5.0, 2.0)
}
.get_size(),
Size::new(10.0, 4.0)
);
assert_eq!(
ShapeDefinition::Polygon { points: Vec::new() }.get_size(),
Size::zero()
);
assert_eq!(
ShapeDefinition::Path {
segments: Vec::new()
}
.get_size(),
Size::zero()
);
}
#[test]
fn shape_definition_negative_circle_radius_yields_a_negative_size() {
let s = ShapeDefinition::Circle { radius: -10.0 }.get_size();
assert_eq!(s.width, -20.0);
assert_eq!(s.height, -20.0);
}
#[test]
fn shape_definition_path_of_only_close_segments_is_zero_sized() {
let s = ShapeDefinition::Path {
segments: vec![PathSegment::Close, PathSegment::Close],
}
.get_size();
assert_eq!(s, Size::zero(), "Close contributes no points");
}
#[test]
fn shape_definition_path_bounding_box_includes_control_points() {
let s = ShapeDefinition::Path {
segments: vec![
PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
PathSegment::QuadTo {
control: Point { x: 50.0, y: 100.0 },
end: Point { x: 100.0, y: 0.0 },
},
],
}
.get_size();
assert_eq!(
s,
Size::new(100.0, 100.0),
"the control point (not the true curve extremum) sets the height"
);
}
#[test]
fn shape_boundary_inflate_by_zero_is_identity() {
let r = ShapeBoundary::Rectangle(Rect {
x: 1.0,
y: 2.0,
width: 3.0,
height: 4.0,
});
assert_eq!(r.inflate(0.0), r);
let c = ShapeBoundary::Circle {
center: Point { x: 0.0, y: 0.0 },
radius: 5.0,
};
assert_eq!(c.inflate(0.0), c);
}
#[test]
fn shape_boundary_inflate_rectangle_clamps_negative_dimensions_to_zero() {
let r = ShapeBoundary::Rectangle(Rect {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
});
match r.inflate(-100.0) {
ShapeBoundary::Rectangle(out) => {
assert_eq!(out.width, 0.0, "over-deflation must clamp, not go negative");
assert_eq!(out.height, 0.0);
assert_eq!(out.x, 100.0, "the origin is NOT clamped");
}
other => panic!("expected Rectangle, got {other:?}"),
}
}
#[test]
fn shape_boundary_inflate_nan_margin_zeroes_the_rectangle_extent() {
let r = ShapeBoundary::Rectangle(Rect {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
});
match r.inflate(f32::NAN) {
ShapeBoundary::Rectangle(out) => {
assert_eq!(out.width, 0.0);
assert_eq!(out.height, 0.0);
assert!(out.x.is_nan());
}
other => panic!("expected Rectangle, got {other:?}"),
}
}
#[test]
fn shape_boundary_inflate_circle_radius_is_unclamped() {
let c = ShapeBoundary::Circle {
center: Point { x: 1.0, y: 2.0 },
radius: 5.0,
};
match c.inflate(-50.0) {
ShapeBoundary::Circle { center, radius } => {
assert_eq!(center, Point { x: 1.0, y: 2.0 });
assert_eq!(radius, -45.0, "circle radius is NOT clamped at 0 (unlike Rect)");
}
other => panic!("expected Circle, got {other:?}"),
}
match c.inflate(f32::INFINITY) {
ShapeBoundary::Circle { radius, .. } => assert!(radius.is_infinite()),
other => panic!("expected Circle, got {other:?}"),
}
}
#[test]
fn shape_boundary_inflate_is_a_documented_no_op_for_polygon_and_path() {
let p = ShapeBoundary::Polygon {
points: vec![Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
};
assert_eq!(p.inflate(10.0), p, "polygon inflation is not implemented");
let path = ShapeBoundary::Path {
segments: vec![PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
};
assert_eq!(path.inflate(10.0), path, "path inflation is not implemented");
}
#[test]
fn resolve_effective_alignment_passes_through_for_non_last_lines() {
for ta in [
TextAlign::Left,
TextAlign::Right,
TextAlign::Center,
TextAlign::Justify,
TextAlign::Start,
TextAlign::End,
TextAlign::JustifyAll,
] {
assert_eq!(
resolve_effective_alignment(ta, TextAlign::Right, false),
ta,
"text-align-last must not touch a non-last line"
);
}
}
#[test]
fn resolve_effective_alignment_last_line_justify_degrades_to_start() {
assert_eq!(
resolve_effective_alignment(TextAlign::Justify, TextAlign::default(), true),
TextAlign::Start
);
assert_eq!(
resolve_effective_alignment(TextAlign::Center, TextAlign::default(), true),
TextAlign::Center,
"non-justify alignments survive onto the last line"
);
}
#[test]
fn resolve_effective_alignment_explicit_text_align_last_left_is_indistinguishable_from_auto() {
assert_eq!(
resolve_effective_alignment(TextAlign::Center, TextAlign::Left, true),
TextAlign::Center
);
assert_eq!(
resolve_effective_alignment(TextAlign::Center, TextAlign::Right, true),
TextAlign::Right
);
assert_eq!(
resolve_effective_alignment(TextAlign::Justify, TextAlign::Justify, true),
TextAlign::Justify
);
}
#[test]
fn spacing_resolve_px_default_is_zero_and_font_size_independent() {
assert_eq!(Spacing::default(), Spacing::Px(0));
assert_eq!(Spacing::default().resolve_px(16.0), 0.0);
assert_eq!(Spacing::Px(0).resolve_px(f32::NAN), 0.0);
}
#[test]
fn spacing_resolve_px_at_i32_extremes_stays_finite() {
let hi = Spacing::Px(i32::MAX).resolve_px(16.0);
let lo = Spacing::Px(i32::MIN).resolve_px(16.0);
assert!(hi.is_finite() && hi > 2.0e9, "got {hi}");
assert!(lo.is_finite() && lo < -2.0e9, "got {lo}");
assert_eq!(lo, -2147483648.0);
}
#[test]
fn spacing_resolve_px_em_scales_with_font_size() {
assert_eq!(Spacing::Em(2.0).resolve_px(16.0), 32.0);
assert_eq!(Spacing::Em(2.0).resolve_px(0.0), 0.0);
assert_eq!(Spacing::Em(-0.5).resolve_px(16.0), -8.0);
assert_eq!(Spacing::PxF(0.4).resolve_px(999.0), 0.4, "PxF ignores font size");
}
#[test]
fn spacing_resolve_px_nan_and_overflow_are_defined() {
assert!(Spacing::Em(f32::NAN).resolve_px(16.0).is_nan());
assert!(Spacing::Em(1.0).resolve_px(f32::NAN).is_nan());
assert!(Spacing::PxF(f32::NAN).resolve_px(16.0).is_nan());
assert!(Spacing::Em(f32::MAX).resolve_px(2.0).is_infinite());
assert!(Spacing::Em(0.0).resolve_px(f32::INFINITY).is_nan());
}
#[test]
fn spacing_px_and_pxf_of_the_same_value_are_distinct_cache_keys() {
assert_ne!(Spacing::Px(1), Spacing::PxF(1.0));
assert_ne!(hash_of(&Spacing::Px(1)), hash_of(&Spacing::PxF(1.0)));
assert_eq!(Spacing::Px(1).resolve_px(16.0), Spacing::PxF(1.0).resolve_px(16.0));
}
#[test]
fn bidi_direction_is_rtl() {
assert!(!BidiDirection::Ltr.is_rtl());
assert!(BidiDirection::Rtl.is_rtl());
}
#[test]
fn bidi_level_parity_defines_rtl_across_the_whole_u8_range() {
for lvl in [0_u8, 1, 2, 3, 126, 127, 254, u8::MAX] {
let b = BidiLevel::new(lvl);
assert_eq!(b.level(), lvl, "level() must round-trip new()");
assert_eq!(b.is_rtl(), lvl % 2 == 1, "odd embedding levels are RTL");
}
}
#[test]
fn writing_mode_is_advance_horizontal_for_every_variant() {
assert!(WritingMode::HorizontalTb.is_advance_horizontal());
assert!(WritingMode::SidewaysRl.is_advance_horizontal());
assert!(WritingMode::SidewaysLr.is_advance_horizontal());
assert!(!WritingMode::VerticalRl.is_advance_horizontal());
assert!(!WritingMode::VerticalLr.is_advance_horizontal());
assert_eq!(WritingMode::default(), WritingMode::HorizontalTb);
}
#[test]
fn writing_mode_get_direction_only_horizontal_defers_to_content() {
assert_eq!(WritingMode::HorizontalTb.get_direction(), None);
assert_eq!(WritingMode::VerticalRl.get_direction(), Some(BidiDirection::Rtl));
assert_eq!(WritingMode::VerticalLr.get_direction(), Some(BidiDirection::Ltr));
assert_eq!(WritingMode::SidewaysRl.get_direction(), Some(BidiDirection::Rtl));
assert_eq!(WritingMode::SidewaysLr.get_direction(), Some(BidiDirection::Ltr));
}
#[test]
fn unified_constraints_default_is_horizontal_max_content() {
let c = UnifiedConstraints::default();
assert!(!c.is_vertical());
assert_eq!(c.available_width, AvailableSpace::MaxContent);
assert_eq!(c.columns, 1);
assert_eq!(c, UnifiedConstraints::default());
assert_eq!(
hash_of(&c),
hash_of(&UnifiedConstraints::default()),
"Hash/Eq must agree for the default constraints"
);
}
#[test]
fn unified_constraints_is_vertical_only_for_the_two_vertical_modes() {
let mut c = UnifiedConstraints::default();
for (wm, want) in [
(WritingMode::HorizontalTb, false),
(WritingMode::VerticalRl, true),
(WritingMode::VerticalLr, true),
(WritingMode::SidewaysRl, false),
(WritingMode::SidewaysLr, false),
] {
c.writing_mode = Some(wm);
assert_eq!(c.is_vertical(), want, "{wm:?}");
}
c.writing_mode = None;
assert!(!c.is_vertical());
}
#[test]
fn unified_constraints_direction_uses_fallback_unless_the_writing_mode_forces_one() {
let mut c = UnifiedConstraints::default();
assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
c.writing_mode = Some(WritingMode::HorizontalTb);
assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
c.writing_mode = Some(WritingMode::VerticalRl);
assert_eq!(c.direction(BidiDirection::Ltr), BidiDirection::Rtl);
}
#[test]
fn unified_constraints_resolved_line_height_uses_the_strut_for_normal() {
let mut c = UnifiedConstraints::default();
assert_eq!(
c.resolved_line_height(),
DEFAULT_STRUT_ASCENT + DEFAULT_STRUT_DESCENT
);
assert_eq!(c.resolved_line_height(), 16.0);
c.line_height = LineHeight::Px(0.0);
assert_eq!(c.resolved_line_height(), 0.0, "an explicit 0 is honoured");
c.line_height = LineHeight::Px(-5.0);
assert_eq!(c.resolved_line_height(), -5.0);
c.line_height = LineHeight::Px(f32::NAN);
assert!(c.resolved_line_height().is_nan());
}
#[test]
fn unified_constraints_partial_eq_is_rounding_tolerant() {
let mut a = UnifiedConstraints::default();
let mut b = UnifiedConstraints::default();
a.strut_ascent = 12.8;
b.strut_ascent = 12.9;
assert_eq!(a, b, "12.8 and 12.9 both round to 13");
b.strut_ascent = 14.0;
assert_ne!(a, b);
}
#[test]
fn text_decoration_from_css_maps_each_variant_exclusively() {
use azul_css::props::style::text::StyleTextDecoration;
let none = TextDecoration::from_css(StyleTextDecoration::None);
assert_eq!(none, TextDecoration::default());
assert!(!none.underline && !none.strikethrough && !none.overline);
let u = TextDecoration::from_css(StyleTextDecoration::Underline);
assert!(u.underline && !u.strikethrough && !u.overline);
let o = TextDecoration::from_css(StyleTextDecoration::Overline);
assert!(!o.underline && !o.strikethrough && o.overline);
let lt = TextDecoration::from_css(StyleTextDecoration::LineThrough);
assert!(!lt.underline && lt.strikethrough && !lt.overline);
}
#[test]
fn inline_border_info_default_has_no_border_and_no_chrome() {
let b = InlineBorderInfo::default();
assert!(!b.has_border());
assert!(!b.has_chrome());
assert_eq!(b.left_inset(), 0.0);
assert_eq!(b.right_inset(), 0.0);
assert_eq!(b.top_inset(), 0.0);
assert_eq!(b.bottom_inset(), 0.0);
}
#[test]
fn inline_border_info_negative_widths_do_not_count_as_a_border() {
let b = InlineBorderInfo {
top: -1.0,
right: -1.0,
bottom: -1.0,
left: -1.0,
..InlineBorderInfo::default()
};
assert!(!b.has_border(), "the predicate is strictly `> 0.0`");
assert!(!b.has_chrome());
assert_eq!(b.left_inset(), -1.0);
}
#[test]
fn inline_border_info_padding_alone_is_chrome_but_not_a_border() {
let b = InlineBorderInfo {
padding_left: 4.0,
..InlineBorderInfo::default()
};
assert!(!b.has_border());
assert!(b.has_chrome());
assert_eq!(b.left_inset(), 4.0);
}
#[test]
fn inline_border_info_nan_border_width_is_not_a_border() {
let b = InlineBorderInfo {
top: f32::NAN,
..InlineBorderInfo::default()
};
assert!(!b.has_border(), "NaN > 0.0 is false");
assert!(b.top_inset().is_nan(), "but the inset still carries the NaN");
}
#[test]
fn inline_border_info_split_insets_swap_edges_in_rtl() {
let base = InlineBorderInfo {
left: 2.0,
right: 3.0,
padding_left: 1.0,
padding_right: 1.0,
..InlineBorderInfo::default()
};
let ltr_first = InlineBorderInfo {
is_first_fragment: true,
is_last_fragment: false,
..base
};
assert_eq!(ltr_first.left_inset(), 3.0);
assert_eq!(ltr_first.right_inset(), 0.0);
let ltr_last = InlineBorderInfo {
is_first_fragment: false,
is_last_fragment: true,
..base
};
assert_eq!(ltr_last.left_inset(), 0.0);
assert_eq!(ltr_last.right_inset(), 4.0);
let rtl_first = InlineBorderInfo {
is_first_fragment: true,
is_last_fragment: false,
is_rtl: true,
..base
};
assert_eq!(rtl_first.left_inset(), 0.0);
assert_eq!(rtl_first.right_inset(), 4.0);
let rtl_last = InlineBorderInfo {
is_first_fragment: false,
is_last_fragment: true,
is_rtl: true,
..base
};
assert_eq!(rtl_last.left_inset(), 3.0);
assert_eq!(rtl_last.right_inset(), 0.0);
let middle = InlineBorderInfo {
is_first_fragment: false,
is_last_fragment: false,
..base
};
assert_eq!(middle.left_inset(), 0.0);
assert_eq!(middle.right_inset(), 0.0);
let tall = InlineBorderInfo {
top: 1.0,
bottom: 2.0,
padding_top: 3.0,
padding_bottom: 4.0,
is_first_fragment: false,
is_last_fragment: false,
..InlineBorderInfo::default()
};
assert_eq!(tall.top_inset(), 4.0);
assert_eq!(tall.bottom_inset(), 6.0);
}
#[test]
fn style_layout_eq_ignores_render_only_properties() {
let a = StyleProperties::default();
let b = StyleProperties {
color: ColorU {
r: 255,
g: 0,
b: 0,
a: 255,
},
background_color: Some(ColorU::TRANSPARENT),
text_decoration: TextDecoration {
underline: true,
strikethrough: false,
overline: false,
},
border: Some(InlineBorderInfo::default()),
..StyleProperties::default()
};
assert_ne!(a, b, "the full PartialEq DOES see the colour change");
assert!(
a.layout_eq(&b),
"but layout_eq must ignore colour/decoration/border"
);
assert_eq!(a.layout_hash(), b.layout_hash());
}
#[test]
fn style_layout_eq_sees_sub_pixel_font_size_changes() {
let a = StyleProperties::default();
let b = StyleProperties {
font_size_px: 16.4,
..StyleProperties::default()
};
assert!(
!a.layout_eq(&b),
"16.0 vs 16.4 must NOT share a shaping-cache entry"
);
}
#[test]
fn style_layout_eq_sees_spacing_and_font_stack_changes() {
let base = StyleProperties::default();
let spaced = StyleProperties {
letter_spacing: Spacing::PxF(0.5),
..StyleProperties::default()
};
assert!(!base.layout_eq(&spaced));
let worded = StyleProperties {
word_spacing: Spacing::Em(0.1),
..StyleProperties::default()
};
assert!(!base.layout_eq(&worded));
let other_font = StyleProperties {
font_stack: FontStack::Stack(vec![sel("Arial")]),
..StyleProperties::default()
};
assert!(!base.layout_eq(&other_font));
let vertical = StyleProperties {
writing_mode: WritingMode::VerticalRl,
..StyleProperties::default()
};
assert!(!base.layout_eq(&vertical));
}
#[test]
fn style_layout_hash_is_stable_across_repeated_calls() {
let s = StyleProperties::default();
assert_eq!(s.layout_hash(), s.layout_hash());
assert!(s.layout_eq(&StyleProperties::default()));
}
#[test]
fn style_layout_eq_treats_two_nan_font_sizes_as_equal() {
let a = StyleProperties {
font_size_px: f32::NAN,
..StyleProperties::default()
};
let b = StyleProperties {
font_size_px: f32::NAN,
..StyleProperties::default()
};
assert!(a.layout_eq(&b));
assert!(!a.layout_eq(&StyleProperties::default()));
}
#[test]
fn style_apply_override_with_an_empty_partial_changes_nothing() {
let base = StyleProperties::default();
let out = base.apply_override(&PartialStyleProperties::default());
assert_eq!(out, base);
}
#[test]
fn style_apply_override_applies_only_the_some_fields() {
let base = StyleProperties::default();
let partial = PartialStyleProperties {
font_size_px: Some(32.0),
letter_spacing: Some(Spacing::PxF(1.5)),
..PartialStyleProperties::default()
};
let out = base.apply_override(&partial);
assert_eq!(out.font_size_px, 32.0);
assert_eq!(out.letter_spacing, Spacing::PxF(1.5));
assert_eq!(out.word_spacing, base.word_spacing);
assert_eq!(out.tab_size, base.tab_size);
assert_eq!(out.font_stack, base.font_stack);
assert!(!out.layout_eq(&base));
}
#[test]
fn style_apply_override_can_inject_nan_font_size() {
let base = StyleProperties::default();
let partial = PartialStyleProperties {
font_size_px: Some(f32::NAN),
..PartialStyleProperties::default()
};
let out = base.apply_override(&partial);
assert!(out.font_size_px.is_nan(), "no validation happens here");
}
#[test]
fn classify_character_covers_each_class() {
assert_eq!(classify_character(0x0020), CharacterClass::Space);
assert_eq!(classify_character(0x00A0), CharacterClass::Space);
assert_eq!(classify_character(0x3000), CharacterClass::Space);
assert_eq!(classify_character('.' as u32), CharacterClass::Punctuation);
assert_eq!(classify_character('~' as u32), CharacterClass::Punctuation);
assert_eq!(classify_character('a' as u32), CharacterClass::Letter);
assert_eq!(classify_character(0x4E00), CharacterClass::Ideograph);
assert_eq!(classify_character(0x9FFF), CharacterClass::Ideograph);
assert_eq!(classify_character(0x0301), CharacterClass::Combining);
}
#[test]
fn classify_character_at_u32_extremes_defaults_to_letter() {
assert_eq!(classify_character(0), CharacterClass::Letter);
assert_eq!(classify_character(u32::MAX), CharacterClass::Letter);
assert_eq!(classify_character(0x4DFF), CharacterClass::Letter);
assert_eq!(classify_character(0xA000), CharacterClass::Letter);
}
#[test]
fn get_justification_priority_is_strictly_ordered_space_to_combining() {
let p = |c| get_justification_priority(c);
assert_eq!(p(CharacterClass::Space), 0);
assert_eq!(p(CharacterClass::Combining), 255);
assert!(p(CharacterClass::Space) < p(CharacterClass::Punctuation));
assert!(p(CharacterClass::Punctuation) < p(CharacterClass::Ideograph));
assert!(p(CharacterClass::Ideograph) < p(CharacterClass::Letter));
assert!(p(CharacterClass::Letter) < p(CharacterClass::Symbol));
assert!(p(CharacterClass::Symbol) < p(CharacterClass::Combining));
}
#[test]
fn is_hanging_punctuation_char_only_stops_and_commas() {
assert!(is_hanging_punctuation_char(','));
assert!(is_hanging_punctuation_char('.'));
assert!(is_hanging_punctuation_char('\u{3001}'));
assert!(is_hanging_punctuation_char('\u{FF0E}'));
assert!(!is_hanging_punctuation_char(';'));
assert!(!is_hanging_punctuation_char(' '));
assert!(!is_hanging_punctuation_char('\0'));
assert!(!is_hanging_punctuation_char(char::MAX));
}
#[test]
fn is_word_char_is_alphanumeric_or_underscore() {
assert!(is_word_char('a'));
assert!(is_word_char('Z'));
assert!(is_word_char('9'));
assert!(is_word_char('_'));
assert!(is_word_char('é'), "non-ASCII letters are word chars");
assert!(is_word_char('中'), "ideographs are alphanumeric");
assert!(!is_word_char(' '));
assert!(!is_word_char('-'));
assert!(!is_word_char('.'));
assert!(!is_word_char('\u{00A0}'));
assert!(!is_word_char('\0'));
}
#[test]
fn is_word_separator_char_excludes_tabs_and_fixed_width_spaces() {
assert!(is_word_separator_char(' '));
assert!(is_word_separator_char('\u{00A0}'), "NBSP IS a word separator");
assert!(is_word_separator_char('\u{1680}'));
assert!(is_word_separator_char('\u{202F}'));
assert!(is_word_separator_char('\u{10100}'));
assert!(!is_word_separator_char('\u{2000}'));
assert!(!is_word_separator_char('\u{200A}'));
assert!(!is_word_separator_char('\u{3000}'), "ideographic space excluded");
assert!(!is_word_separator_char('\t'));
assert!(!is_word_separator_char('\n'));
assert!(!is_word_separator_char('.'));
assert!(!is_word_separator_char(char::MAX));
}
#[test]
fn is_cursive_script_char_boundaries() {
assert!(!is_cursive_script_char('\u{05FF}'), "one below Arabic");
assert!(is_cursive_script_char('\u{0600}'), "Arabic block start");
assert!(is_cursive_script_char('\u{06FF}'), "Arabic block end");
assert!(is_cursive_script_char('\u{0700}'), "Syriac");
assert!(is_cursive_script_char('\u{1800}'), "Mongolian");
assert!(is_cursive_script_char('\u{10D00}'), "Hanifi Rohingya (astral)");
assert!(!is_cursive_script_char('a'));
assert!(!is_cursive_script_char('中'));
assert!(!is_cursive_script_char('\0'));
assert!(!is_cursive_script_char(char::MAX));
}
#[test]
fn is_cjk_character_boundaries() {
assert!(is_cjk_character('中')); assert!(is_cjk_character('\u{4E00}'));
assert!(is_cjk_character('\u{9FFF}'));
assert!(is_cjk_character('\u{3040}'), "hiragana block");
assert!(is_cjk_character('\u{30FF}'), "katakana block");
assert!(is_cjk_character('\u{AC00}'), "hangul syllables");
assert!(is_cjk_character('\u{FF01}'), "fullwidth forms");
assert!(!is_cjk_character('\u{4DFF}'), "one below the ideograph block");
assert!(!is_cjk_character('a'));
assert!(!is_cjk_character('\0'));
assert!(!is_cjk_character(char::MAX));
}
#[test]
fn break_control_predicates_are_disjoint() {
assert!(is_break_suppressing_control('\u{200D}'));
assert!(is_break_suppressing_control('\u{2060}'));
assert!(is_break_suppressing_control('\u{FEFF}'));
assert!(!is_break_suppressing_control(' '));
assert!(!is_break_suppressing_control('\u{200B}'));
assert!(is_break_forcing_control('\u{200B}'));
assert!(is_break_forcing_control('\u{2028}'));
assert!(is_break_forcing_control('\u{2029}'));
assert!(!is_break_forcing_control(' '));
assert!(!is_break_forcing_control('\u{200D}'));
for ch in ['\u{200D}', '\u{2060}', '\u{FEFF}', '\u{200B}', '\u{2028}'] {
assert!(
!(is_break_suppressing_control(ch) && is_break_forcing_control(ch)),
"{ch:?} cannot both force and suppress a break"
);
}
}
#[test]
fn is_small_kana_matches_only_the_cj_class() {
assert!(is_small_kana('っ'));
assert!(is_small_kana('ゃ'));
assert!(is_small_kana('ッ'));
assert!(is_small_kana('ー'), "prolonged sound mark is class CJ");
assert!(!is_small_kana('つ'), "the FULL-size kana is not CJ");
assert!(!is_small_kana('中'));
assert!(!is_small_kana('a'));
assert!(!is_small_kana('\0'));
}
#[test]
fn is_cjk_break_allowed_by_strictness_per_level() {
use LineBreakStrictness::{Anywhere, Auto, Loose, Normal, Strict};
for ch in ['っ', '\u{301C}', '\u{2010}', '中'] {
assert!(is_cjk_break_allowed_by_strictness(ch, None, Anywhere), "{ch:?}");
assert!(is_cjk_break_allowed_by_strictness(ch, None, Loose), "{ch:?}");
}
for level in [Normal, Auto] {
assert!(!is_cjk_break_allowed_by_strictness('\u{2010}', None, level));
assert!(!is_cjk_break_allowed_by_strictness('\u{2013}', None, level));
assert!(is_cjk_break_allowed_by_strictness('っ', None, level));
assert!(is_cjk_break_allowed_by_strictness('中', None, level));
}
assert!(!is_cjk_break_allowed_by_strictness('っ', None, Strict));
assert!(!is_cjk_break_allowed_by_strictness('ー', None, Strict));
assert!(!is_cjk_break_allowed_by_strictness('\u{301C}', None, Strict));
assert!(!is_cjk_break_allowed_by_strictness('\u{30A0}', None, Strict));
assert!(is_cjk_break_allowed_by_strictness('中', None, Strict));
assert_eq!(
is_cjk_break_allowed_by_strictness('中', Some('x'), Strict),
is_cjk_break_allowed_by_strictness('中', None, Strict)
);
}
fn plain_glyph(codepoint: char, advance: f32) -> Glyph {
Glyph {
glyph_id: 1,
codepoint,
font_hash: 7,
font_metrics: std_metrics(),
style: style(),
source: GlyphSource::Char,
logical_byte_index: 0,
logical_byte_len: codepoint.len_utf8(),
content_index: 0,
cluster: 0,
advance,
kerning: 0.0,
offset: Point { x: 0.0, y: 0.0 },
vertical_advance: advance,
vertical_origin_y: 0.0,
vertical_bearing: Point { x: 0.0, y: 0.0 },
orientation: GlyphOrientation::Horizontal,
script: Script::Latin,
bidi_level: BidiLevel::new(0),
}
}
#[test]
fn glyph_bounds_is_advance_by_resolved_line_height() {
let g = plain_glyph('a', 9.5);
let b = g.bounds();
assert_eq!(b.x, 0.0);
assert_eq!(b.y, 0.0);
assert_eq!(b.width, 9.5);
approx(b.height, 16.0); }
#[test]
fn glyph_bounds_with_zero_advance_and_zero_upem_does_not_panic() {
let mut g = plain_glyph('a', 0.0);
g.font_metrics = metrics(0, 0.0, 0.0, 0.0);
let b = g.bounds();
assert_eq!(b.width, 0.0);
approx(b.height, 19.2); }
#[test]
fn glyph_whitespace_and_justification_predicates() {
let space = plain_glyph(' ', 4.0);
assert!(space.is_whitespace());
assert_eq!(space.character_class(), CharacterClass::Space);
assert!(!space.can_justify(), "whitespace is never itself justified");
assert_eq!(space.justification_priority(), 0);
assert!(space.break_opportunity_after());
let letter = plain_glyph('a', 8.0);
assert!(!letter.is_whitespace());
assert!(letter.can_justify());
assert_eq!(letter.justification_priority(), 192);
assert!(!letter.break_opportunity_after());
let combining = plain_glyph('\u{0301}', 0.0);
assert!(!combining.is_whitespace());
assert!(!combining.can_justify(), "combining marks are never justified");
assert_eq!(combining.justification_priority(), 255);
}
#[test]
fn glyph_break_opportunity_after_covers_every_hyphen_form() {
assert!(plain_glyph('\u{00AD}', 0.0).break_opportunity_after(), "soft hyphen");
assert!(plain_glyph('\u{002D}', 4.0).break_opportunity_after(), "hyphen-minus");
assert!(plain_glyph('\u{2010}', 4.0).break_opportunity_after(), "U+2010");
assert!(plain_glyph('\t', 8.0).break_opportunity_after(), "tab is whitespace");
assert!(!plain_glyph('\u{2011}', 4.0).break_opportunity_after(), "NON-BREAKING hyphen");
assert!(!plain_glyph('/', 4.0).break_opportunity_after());
}
#[test]
fn shaped_item_as_cluster_only_matches_clusters() {
assert!(cl("a", 8.0).as_cluster().is_some());
assert!(obj(10.0, 10.0, 0.0).as_cluster().is_none());
assert!(brk().as_cluster().is_none());
assert!(tab(8.0, 16.0).as_cluster().is_none());
}
#[test]
fn shaped_item_bounds_of_a_break_is_the_zero_rect() {
assert_eq!(brk().bounds(), Rect::default());
assert_eq!(obj(10.0, 20.0, 0.0).bounds().width, 10.0);
assert_eq!(tab(8.0, 16.0).bounds().height, 16.0);
let c = cl("a", 9.5);
assert_eq!(c.bounds().width, 9.5, "a cluster's width is its advance");
approx(c.bounds().height, 16.0); }
#[test]
fn get_item_measure_sums_advance_and_kerning() {
let st = style();
let mut g1 = shaped_glyph(st.clone(), std_metrics(), 8.0);
g1.kerning = -1.5;
let mut g2 = shaped_glyph(st.clone(), std_metrics(), 8.0);
g2.kerning = 0.5;
let item = make_cluster("ab", 16.0, st, smallvec![g1, g2], gid(0, 0));
assert_eq!(get_item_measure(&item, false), 15.0, "16 + (-1.5) + 0.5");
assert_eq!(
get_item_measure(&item, true),
15.0,
"clusters ignore the is_vertical flag (advance is already axis-relative)"
);
}
#[test]
fn get_item_measure_of_a_break_is_zero_and_objects_switch_axis() {
assert_eq!(get_item_measure(&brk(), false), 0.0);
assert_eq!(get_item_measure(&brk(), true), 0.0);
let o = obj(30.0, 20.0, 0.0);
assert_eq!(get_item_measure(&o, false), 30.0);
assert_eq!(get_item_measure(&o, true), 20.0);
}
#[test]
fn get_item_measure_with_spacing_adds_letter_spacing_but_not_for_cursive() {
let st = styled(|s| s.letter_spacing = Spacing::PxF(2.0));
let latin = cl_styled("a", 10.0, st.clone());
assert_eq!(get_item_measure(&latin, false), 10.0);
assert_eq!(get_item_measure_with_spacing(&latin, false), 12.0);
let arabic = cl_styled("\u{0627}", 10.0, st);
assert_eq!(
get_item_measure_with_spacing(&arabic, false),
10.0,
"letter-spacing is suppressed for cursive scripts (CSS Text 3 App. D)"
);
}
#[test]
fn get_item_measure_with_spacing_adds_word_spacing_only_on_separators() {
let st = styled(|s| {
s.word_spacing = Spacing::PxF(5.0);
s.letter_spacing = Spacing::PxF(1.0);
});
let space = cl_styled(" ", 4.0, st.clone());
assert_eq!(
get_item_measure_with_spacing(&space, false),
10.0,
"4 + letter(1) + word(5)"
);
let letter = cl_styled("a", 8.0, st);
assert_eq!(
get_item_measure_with_spacing(&letter, false),
9.0,
"no word-spacing on a non-separator"
);
assert_eq!(get_item_measure_with_spacing(&brk(), false), 0.0);
}
#[test]
fn is_collapsible_whitespace_is_vacuously_true_for_an_empty_cluster() {
assert!(is_collapsible_whitespace(&cl(" ", 4.0)));
assert!(is_collapsible_whitespace(&cl("\t", 8.0)));
assert!(is_collapsible_whitespace(&cl("\u{1680}", 4.0)));
assert!(is_collapsible_whitespace(&cl(" \t ", 16.0)));
assert!(!is_collapsible_whitespace(&cl("\n", 0.0)), "newline is NOT collapsible here");
assert!(!is_collapsible_whitespace(&cl("a", 8.0)));
assert!(!is_collapsible_whitespace(&cl("a ", 12.0)), "all() — mixed is false");
assert!(!is_collapsible_whitespace(&obj(1.0, 1.0, 0.0)));
assert!(
is_collapsible_whitespace(&cl("", 0.0)),
"an empty cluster counts as collapsible whitespace"
);
}
#[test]
fn is_word_separator_and_zero_width_space_on_items() {
assert!(is_word_separator(&cl(" ", 4.0)));
assert!(is_word_separator(&cl("a b", 20.0)), "any() — one space suffices");
assert!(!is_word_separator(&cl("", 0.0)), "any() on empty is false");
assert!(!is_word_separator(&cl("\u{3000}", 16.0)));
assert!(!is_word_separator(&brk()));
assert!(!is_word_separator(&obj(1.0, 1.0, 0.0)));
assert!(is_zero_width_space(&cl("\u{200B}", 0.0)));
assert!(is_zero_width_space(&cl("a\u{200B}", 8.0)), "contains(), not equals()");
assert!(!is_zero_width_space(&cl(" ", 4.0)));
assert!(!is_zero_width_space(&obj(1.0, 1.0, 0.0)));
}
#[test]
fn can_justify_after_rejects_objects_empty_clusters_and_combining_marks() {
assert!(can_justify_after(&cl("a", 8.0)));
assert!(!can_justify_after(&cl(" ", 4.0)));
assert!(!can_justify_after(&cl("a\u{0301}", 8.0)), "trailing combining mark");
assert!(!can_justify_after(&cl("", 0.0)), "no last char → false");
assert!(
!can_justify_after(&obj(10.0, 10.0, 0.0)),
"CSS 2.2 §9.4.2: never stretch after an atomic inline"
);
assert!(!can_justify_after(&brk()));
}
#[test]
fn is_hanging_punctuation_requires_a_single_glyph_cluster() {
assert!(is_hanging_punctuation(&cl(".", 4.0)));
assert!(is_hanging_punctuation(&cl(",", 4.0)));
assert!(!is_hanging_punctuation(&cl("a", 8.0)));
assert!(!is_hanging_punctuation(&cl("", 0.0)), "no first char");
assert!(!is_hanging_punctuation(&obj(1.0, 1.0, 0.0)));
let st = style();
let g1 = shaped_glyph(st.clone(), std_metrics(), 4.0);
let g2 = shaped_glyph(st.clone(), std_metrics(), 4.0);
let two = make_cluster(".", 8.0, st, smallvec![g1, g2], gid(0, 0));
assert!(!is_hanging_punctuation(&two));
}
#[test]
fn cluster_script_predicates() {
let arabic = cl("\u{0627}", 10.0);
let latin = cl("a", 8.0);
let cjk = cl("中", 16.0);
assert!(is_cursive_script_cluster(arabic.as_cluster().unwrap()));
assert!(!is_cursive_script_cluster(latin.as_cluster().unwrap()));
assert!(
!is_cursive_script_cluster(cl("", 0.0).as_cluster().unwrap()),
"empty cluster has no first char"
);
assert!(is_cjk_cluster(cjk.as_cluster().unwrap()));
assert!(!is_cjk_cluster(latin.as_cluster().unwrap()));
assert!(
!is_arabic_cluster(arabic.as_cluster().unwrap()),
"the fixture's glyph carries Script::Latin, so the text alone is not enough"
);
let st = style();
let mut g = shaped_glyph(st.clone(), std_metrics(), 10.0);
g.script = Script::Arabic;
let real_arabic = make_cluster("\u{0627}", 10.0, st, smallvec![g], gid(0, 0));
assert!(is_arabic_cluster(real_arabic.as_cluster().unwrap()));
}
#[test]
fn cluster_is_word_boundary_for_punctuation_and_whitespace() {
assert!(cluster_is_word_boundary(cl(" ", 4.0).as_cluster().unwrap()));
assert!(cluster_is_word_boundary(cl(".", 4.0).as_cluster().unwrap()));
assert!(cluster_is_word_boundary(cl("", 0.0).as_cluster().unwrap()), "vacuous");
assert!(!cluster_is_word_boundary(cl("a", 8.0).as_cluster().unwrap()));
assert!(!cluster_is_word_boundary(cl("_", 8.0).as_cluster().unwrap()));
}
#[test]
fn get_baseline_for_item_only_defined_for_clusters_and_boxes() {
assert_eq!(get_baseline_for_item(&brk()), None);
assert_eq!(get_baseline_for_item(&tab(8.0, 16.0)), None);
assert_eq!(get_baseline_for_item(&obj(10.0, 20.0, 3.0)), Some(3.0));
approx(
get_baseline_for_item(&cl("a", 8.0)).expect("a glyph-bearing cluster has a baseline"),
12.8,
);
assert_eq!(
get_baseline_for_item(&cl_no_glyphs("", 0.0)),
None,
"a glyph-less cluster has no baseline"
);
}
#[test]
fn get_item_vertical_metrics_approx_for_every_variant() {
let (a, d) = get_item_vertical_metrics_approx(&cl("a", 8.0));
approx(a, 12.8);
approx(d, 3.2);
let (a, d) = get_item_vertical_metrics_approx(&cl_no_glyphs("", 0.0));
approx(a, 19.2 * FALLBACK_ASCENT_RATIO);
approx(d, 19.2 * FALLBACK_DESCENT_RATIO);
assert_eq!(get_item_vertical_metrics_approx(&obj(10.0, 20.0, 5.0)), (20.0, 0.0));
assert_eq!(get_item_vertical_metrics_approx(&brk()), (0.0, 0.0));
let (a, d) = get_item_vertical_metrics_approx(&tab(8.0, 10.0));
approx(a, 8.0);
approx(d, 2.0);
}
#[test]
fn get_item_vertical_metrics_approx_skips_zero_upem_glyphs() {
let st = style();
let g = shaped_glyph(st.clone(), metrics(0, 800.0, -200.0, 0.0), 8.0);
let item = make_cluster("a", 8.0, st, smallvec![g], gid(0, 0));
assert_eq!(
get_item_vertical_metrics_approx(&item),
(0.0, 0.0),
"a zero-upem glyph is skipped rather than producing inf/NaN metrics"
);
}
#[test]
fn get_item_vertical_metrics_uses_the_strut_for_glyphless_clusters() {
let c = UnifiedConstraints::default();
let (a, d) = get_item_vertical_metrics(&cl_no_glyphs("", 0.0), &c);
approx(a, DEFAULT_STRUT_ASCENT + 1.6);
approx(d, DEFAULT_STRUT_DESCENT + 1.6);
assert_eq!(get_item_vertical_metrics(&brk(), &c), (0.0, 0.0));
let (a, d) = get_item_vertical_metrics(&obj(10.0, 10.0, 30.0), &c);
assert_eq!(a, 0.0, "baseline_offset > height must clamp the ascent at 0");
assert_eq!(d, 30.0);
}
#[test]
fn get_item_vertical_align_only_for_objects_with_image_or_shape_content() {
assert_eq!(get_item_vertical_align(&cl("a", 8.0)), None);
assert_eq!(get_item_vertical_align(&brk()), None);
assert_eq!(get_item_vertical_align(&obj(1.0, 1.0, 0.0)), None);
let img = ShapedItem::Object {
source: ci(0, 0),
bounds: Rect::default(),
baseline_offset: 0.0,
content: InlineContent::Image(InlineImage {
source: ImageSource::Placeholder(Size::new(10.0, 10.0)),
intrinsic_size: Size::new(10.0, 10.0),
display_size: None,
baseline_offset: 0.0,
alignment: VerticalAlign::Top,
object_fit: ObjectFit::Fill,
}),
};
assert_eq!(get_item_vertical_align(&img), Some(VerticalAlign::Top));
}
#[test]
fn no_break_space_is_a_word_separator_but_never_a_break_opportunity() {
let nbsp = cl("\u{00A0}", 4.0);
assert!(is_word_separator( ), "NBSP participates in word-spacing");
assert!(
!is_break_opportunity( ),
"...but must NOT offer a soft wrap (10\\u{{00A0}}km must not wrap)"
);
assert!(!is_break_opportunity_with_word_break(
 ,
WordBreak::BreakAll,
Hyphens::Auto
));
for ch in ['\u{202F}', '\u{2060}', '\u{FEFF}'] {
let item = cl(&ch.to_string(), 4.0);
assert!(
!is_break_opportunity_with_word_break(&item, WordBreak::BreakAll, Hyphens::Auto),
"{ch:?} must suppress breaks"
);
}
}
#[test]
fn zero_width_space_breaks_even_under_keep_all() {
let zwsp = cl("\u{200B}", 0.0);
assert!(is_break_opportunity(&zwsp));
for wb in [WordBreak::Normal, WordBreak::BreakAll, WordBreak::KeepAll] {
assert!(
is_break_opportunity_with_word_break(&zwsp, wb, Hyphens::None),
"ZWSP must always break ({wb:?})"
);
}
}
#[test]
fn word_break_modes_change_cjk_break_opportunities() {
let cjk = cl("中", 16.0);
assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::Normal, Hyphens::Manual));
assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::BreakAll, Hyphens::Manual));
assert!(
!is_break_opportunity_with_word_break(&cjk, WordBreak::KeepAll, Hyphens::Manual),
"keep-all suppresses inter-ideograph breaks"
);
let latin = cl("a", 8.0);
assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::Normal, Hyphens::Manual));
assert!(
is_break_opportunity_with_word_break(&latin, WordBreak::BreakAll, Hyphens::Manual),
"break-all makes every cluster breakable"
);
assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::KeepAll, Hyphens::Manual));
}
#[test]
fn soft_hyphen_break_depends_on_the_hyphens_property() {
let shy = cl("\u{00AD}", 0.0);
assert!(!is_break_opportunity_with_word_break(
­,
WordBreak::Normal,
Hyphens::None
));
assert!(is_break_opportunity_with_word_break(
­,
WordBreak::Normal,
Hyphens::Manual
));
assert!(is_break_opportunity_with_word_break(
­,
WordBreak::Normal,
Hyphens::Auto
));
}
#[test]
fn trailing_hyphen_and_slash_always_break_regardless_of_hyphens() {
for text in ["co-", "co\u{2010}", "a/"] {
let item = cl(text, 10.0);
assert!(
is_break_opportunity_with_word_break(&item, WordBreak::KeepAll, Hyphens::None),
"{text:?} must offer a break after it even with hyphens:none"
);
}
assert!(!is_break_opportunity_with_word_break(
&cl("-a", 10.0),
WordBreak::Normal,
Hyphens::None
));
}
#[test]
fn atomic_inlines_are_break_opportunities_but_breaks_and_tabs_differ() {
assert!(is_break_opportunity(&obj(10.0, 10.0, 0.0)), "CSS Text 3 §5.1");
assert!(is_break_opportunity(&brk()));
assert!(!is_break_opportunity(&tab(8.0, 16.0)), "a Tab is not itself a wrap point");
assert!(is_break_opportunity(&cl(" ", 4.0)));
assert!(!is_break_opportunity(&cl("a", 8.0)));
}
#[test]
fn merge_segments_of_zero_or_one_segment_is_identity() {
assert!(merge_segments(Vec::new()).is_empty());
let one = vec![LineSegment {
start_x: 5.0,
width: 3.0,
priority: 0,
}];
let out = merge_segments(one);
assert_eq!(out.len(), 1);
assert_eq!(out[0].start_x, 5.0);
}
#[test]
fn merge_segments_joins_overlapping_and_touching_spans() {
let segs = vec![
LineSegment {
start_x: 0.0,
width: 10.0,
priority: 0,
},
LineSegment {
start_x: 5.0,
width: 10.0,
priority: 0,
}, LineSegment {
start_x: 15.0,
width: 5.0,
priority: 0,
}, LineSegment {
start_x: 100.0,
width: 5.0,
priority: 0,
}, ];
let out = merge_segments(segs);
assert_eq!(out.len(), 2, "three touching spans collapse into one");
assert_eq!(out[0].start_x, 0.0);
assert_eq!(out[0].width, 20.0);
assert_eq!(out[1].start_x, 100.0);
}
#[test]
fn merge_segments_sorts_unordered_input() {
let segs = vec![
LineSegment {
start_x: 50.0,
width: 5.0,
priority: 0,
},
LineSegment {
start_x: 0.0,
width: 5.0,
priority: 0,
},
];
let out = merge_segments(segs);
assert_eq!(out.len(), 2);
assert_eq!(out[0].start_x, 0.0);
assert_eq!(out[1].start_x, 50.0);
}
#[test]
fn merge_segments_is_nan_tolerant() {
let segs = vec![
LineSegment {
start_x: 0.0,
width: 10.0,
priority: 0,
},
LineSegment {
start_x: f32::NAN,
width: 10.0,
priority: 0,
},
];
let out = merge_segments(segs);
assert!(!out.is_empty(), "NaN input must not abort the merge");
}
#[test]
fn polygon_line_intersection_needs_at_least_three_points() {
assert!(polygon_line_intersection(&[], 0.0, 1.0).is_empty());
assert!(polygon_line_intersection(&[Point { x: 0.0, y: 0.0 }], 0.0, 1.0).is_empty());
assert!(polygon_line_intersection(
&[Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
0.0,
1.0
)
.is_empty());
}
#[test]
fn polygon_line_intersection_narrows_across_a_triangle() {
let tri = [
Point { x: 0.0, y: 0.0 },
Point { x: 100.0, y: 0.0 },
Point { x: 0.0, y: 100.0 },
];
let top = polygon_line_intersection(&tri, 10.0, 1.0);
let bot = polygon_line_intersection(&tri, 80.0, 1.0);
assert_eq!(top.len(), 1);
assert_eq!(bot.len(), 1);
assert!(
top[0].width > bot[0].width,
"the band must narrow with y ({} !> {})",
top[0].width,
bot[0].width
);
assert!((top[0].width - 89.5).abs() < 1.0);
}
#[test]
fn polygon_line_intersection_outside_the_shape_and_on_nan_scanlines_is_empty() {
let tri = [
Point { x: 0.0, y: 0.0 },
Point { x: 100.0, y: 0.0 },
Point { x: 0.0, y: 100.0 },
];
assert!(
polygon_line_intersection(&tri, 500.0, 1.0).is_empty(),
"a scanline below the shape yields no spans"
);
assert!(
polygon_line_intersection(&tri, f32::NAN, 1.0).is_empty(),
"a NaN scanline must not panic — every crossing test is false"
);
assert!(polygon_line_intersection(&tri, f32::INFINITY, 1.0).is_empty());
}
#[test]
fn polygon_line_intersection_of_a_degenerate_flat_polygon_is_empty() {
let flat = [
Point { x: 0.0, y: 5.0 },
Point { x: 10.0, y: 5.0 },
Point { x: 20.0, y: 5.0 },
];
assert!(polygon_line_intersection(&flat, 4.5, 1.0).is_empty());
}
#[test]
fn path_segments_line_intersection_on_empty_and_degenerate_input() {
assert!(path_segments_line_intersection(&[], 0.0, 1.0).is_empty());
assert!(path_segments_line_intersection(
&[PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
0.0,
1.0
)
.is_empty());
}
#[test]
fn path_segments_line_intersection_of_a_square() {
let sq = vec![
PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
PathSegment::LineTo(Point { x: 100.0, y: 0.0 }),
PathSegment::LineTo(Point {
x: 100.0,
y: 100.0,
}),
PathSegment::LineTo(Point { x: 0.0, y: 100.0 }),
PathSegment::Close,
];
let spans = path_segments_line_intersection(&sq, 50.0, 1.0);
assert_eq!(spans.len(), 1);
assert!((spans[0].0 - 0.0).abs() < 0.01);
assert!((spans[0].1 - 100.0).abs() < 0.01);
assert!(path_segments_line_intersection(&sq, 500.0, 1.0).is_empty());
}
#[test]
fn get_shape_horizontal_spans_rectangle_only_when_the_line_box_overlaps() {
let r = ShapeBoundary::Rectangle(Rect {
x: 10.0,
y: 20.0,
width: 30.0,
height: 40.0,
});
assert_eq!(get_shape_horizontal_spans(&r, 30.0, 10.0), vec![(10.0, 40.0)]);
assert!(get_shape_horizontal_spans(&r, 0.0, 10.0).is_empty(), "above");
assert!(get_shape_horizontal_spans(&r, 100.0, 10.0).is_empty(), "below");
assert!(get_shape_horizontal_spans(&r, 10.0, 10.0).is_empty());
let flat = ShapeBoundary::Rectangle(Rect {
x: 0.0,
y: 0.0,
width: 10.0,
height: 0.0,
});
assert!(get_shape_horizontal_spans(&flat, 0.0, 10.0).is_empty());
}
#[test]
fn get_shape_horizontal_spans_circle_edges_and_zero_radius() {
let c = ShapeBoundary::Circle {
center: Point { x: 50.0, y: 50.0 },
radius: 10.0,
};
let mid = get_shape_horizontal_spans(&c, 49.5, 1.0); assert_eq!(mid.len(), 1);
assert!((mid[0].0 - 40.0).abs() < 0.01);
assert!((mid[0].1 - 60.0).abs() < 0.01);
assert!(get_shape_horizontal_spans(&c, 1000.0, 1.0).is_empty());
let dot = ShapeBoundary::Circle {
center: Point { x: 5.0, y: 5.0 },
radius: 0.0,
};
let spans = get_shape_horizontal_spans(&dot, 4.5, 1.0);
assert_eq!(spans, vec![(5.0, 5.0)], "degenerate but not a panic");
}
#[test]
fn get_shape_horizontal_spans_ellipse_with_zero_radii_is_empty_not_a_panic() {
let e = ShapeBoundary::Ellipse {
center: Point { x: 0.0, y: 0.0 },
radii: Size::zero(),
};
assert!(
get_shape_horizontal_spans(&e, 0.0, 1.0).is_empty(),
"a zero-sized ellipse divides by zero but must not panic"
);
}
#[test]
fn get_shape_horizontal_spans_polygon_delegates_to_the_scanline() {
let p = ShapeBoundary::Polygon {
points: vec![
Point { x: 0.0, y: 0.0 },
Point { x: 100.0, y: 0.0 },
Point { x: 0.0, y: 100.0 },
],
};
let spans = get_shape_horizontal_spans(&p, 10.0, 1.0);
assert_eq!(spans.len(), 1);
assert!(spans[0].1 > spans[0].0);
}
#[test]
fn extract_line_breaks_of_no_items_is_empty_but_keeps_the_width() {
let lb = extract_line_breaks(&[], 640.0);
assert!(lb.line_ranges.is_empty());
assert!(lb.line_widths.is_empty());
assert_eq!(lb.available_width, 640.0);
let nan = extract_line_breaks(&[], f32::NAN);
assert!(nan.available_width.is_nan());
}
#[test]
fn extract_line_breaks_groups_items_by_line_index() {
let items = vec![
pos(cl("a", 10.0), 0.0, 0.0, 0),
pos(cl("b", 10.0), 10.0, 0.0, 0),
pos(cl("c", 10.0), 0.0, 20.0, 1),
];
let lb = extract_line_breaks(&items, 100.0);
assert_eq!(lb.line_ranges, vec![(0, 2), (2, 3)]);
assert_eq!(lb.line_widths, vec![20.0, 10.0]);
assert_eq!(lb.line_ranges.len(), lb.line_widths.len());
}
#[test]
fn extract_line_breaks_splits_on_every_line_index_change_even_going_backwards() {
let items = vec![
pos(cl("a", 10.0), 0.0, 0.0, 0),
pos(cl("b", 10.0), 0.0, 20.0, 1),
pos(cl("c", 10.0), 0.0, 0.0, 0),
];
let lb = extract_line_breaks(&items, 100.0);
assert_eq!(lb.line_ranges.len(), 3);
assert_eq!(lb.line_widths, vec![10.0, 10.0, 10.0]);
}
#[test]
fn try_incremental_relayout_no_dirty_items_is_a_glyph_swap() {
let lb = CachedLineBreaks {
line_ranges: vec![(0, 2)],
line_widths: vec![20.0],
available_width: 100.0,
};
assert!(matches!(
try_incremental_relayout(&[], &[10.0, 10.0], &[10.0, 10.0], &lb),
IncrementalRelayoutResult::GlyphSwap
));
}
#[test]
fn try_incremental_relayout_out_of_range_dirty_index_falls_back_to_full() {
let lb = CachedLineBreaks {
line_ranges: vec![(0, 2)],
line_widths: vec![20.0],
available_width: 100.0,
};
assert!(matches!(
try_incremental_relayout(&[99], &[10.0, 10.0], &[10.0, 10.0], &lb),
IncrementalRelayoutResult::FullRelayout
));
assert!(
matches!(
try_incremental_relayout(&[usize::MAX], &[10.0], &[10.0], &lb),
IncrementalRelayoutResult::FullRelayout
),
"usize::MAX must not index-panic"
);
assert!(matches!(
try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0], &lb),
IncrementalRelayoutResult::FullRelayout
));
}
#[test]
fn try_incremental_relayout_same_width_is_a_glyph_swap() {
let lb = CachedLineBreaks {
line_ranges: vec![(0, 2)],
line_widths: vec![20.0],
available_width: 100.0,
};
assert!(matches!(
try_incremental_relayout(&[0], &[10.0, 10.0], &[10.0005, 10.0], &lb),
IncrementalRelayoutResult::GlyphSwap
));
}
#[test]
fn try_incremental_relayout_shifts_when_it_still_fits_and_reflows_when_it_does_not() {
let lb = CachedLineBreaks {
line_ranges: vec![(0, 2), (2, 4)],
line_widths: vec![20.0, 20.0],
available_width: 100.0,
};
let old = [10.0, 10.0, 10.0, 10.0];
let grew = [10.0, 30.0, 10.0, 10.0]; match try_incremental_relayout(&[1], &old, &grew, &lb) {
IncrementalRelayoutResult::LineShift {
affected_item,
delta,
} => {
assert_eq!(affected_item, 1);
assert_eq!(delta, 20.0);
}
other => panic!("expected LineShift, got {other:?}"),
}
let exploded = [10.0, 10.0, 10.0, 500.0]; match try_incremental_relayout(&[3], &old, &exploded, &lb) {
IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
assert_eq!(reflow_from_line, 1);
}
other => panic!("expected PartialReflow, got {other:?}"),
}
}
#[test]
fn try_incremental_relayout_dirty_item_outside_every_line_range_is_a_full_relayout() {
let lb = CachedLineBreaks {
line_ranges: vec![(0, 1)],
line_widths: vec![10.0],
available_width: 100.0,
};
assert!(matches!(
try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0, 50.0], &lb),
IncrementalRelayoutResult::FullRelayout
));
}
#[test]
fn try_incremental_relayout_with_nan_advances_reflows_rather_than_shifting() {
let lb = CachedLineBreaks {
line_ranges: vec![(0, 1)],
line_widths: vec![10.0],
available_width: 100.0,
};
match try_incremental_relayout(&[0], &[10.0], &[f32::NAN], &lb) {
IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
assert_eq!(reflow_from_line, 0);
}
other => panic!("NaN advance should reflow, got {other:?}"),
}
}
#[test]
fn text_cache_memory_report_total_bytes_sums_only_the_byte_fields() {
let r = TextCacheMemoryReport::default();
assert_eq!(r.total_bytes(), 0);
let full = TextCacheMemoryReport {
logical_items_entries: 1_000_000, logical_items_bytes: 1,
visual_items_entries: 1_000_000, visual_items_bytes: 2,
shaped_items_entries: 1_000_000, shaped_items_bytes: 4,
shaped_glyph_bytes: 8,
shaped_cluster_text_bytes: 16,
per_item_shaped_entries: 1_000_000, per_item_shaped_bytes: 32,
};
assert_eq!(full.total_bytes(), 63, "1+2+4+8+16+32");
}
#[test]
fn text_shaping_cache_new_is_empty_and_reports_zero_bytes() {
let c = TextShapingCache::new();
let r = c.memory_report();
assert_eq!(r.total_bytes(), 0);
assert_eq!(r.logical_items_entries, 0);
assert_eq!(r.per_item_shaped_entries, 0);
assert_eq!(c.generation, 0);
let d = TextShapingCache::default();
assert_eq!(d.memory_report().total_bytes(), 0);
}
#[test]
fn text_shaping_cache_begin_generation_is_idempotent_on_an_empty_cache() {
let mut c = TextShapingCache::new();
for expect in 1..=5_u64 {
c.begin_generation();
assert_eq!(c.generation, expect);
}
assert!(c.per_item_accessed.is_empty());
assert!(c.per_item_shaped.is_empty());
}
#[test]
fn text_shaping_cache_begin_generation_evicts_unaccessed_per_item_entries() {
let mut c = TextShapingCache::new();
c.per_item_shaped.insert(
1,
Arc::new(PerItemShapedEntry {
clusters: vec![cl("a", 8.0)],
total_advance: 8.0,
}),
);
c.per_item_shaped.insert(
2,
Arc::new(PerItemShapedEntry {
clusters: Vec::new(),
total_advance: 0.0,
}),
);
c.begin_generation();
assert_eq!(c.per_item_shaped.len(), 2, "gen 0 never evicts");
c.per_item_accessed.insert(1);
c.begin_generation();
assert_eq!(c.per_item_shaped.len(), 1);
assert!(c.per_item_shaped.contains_key(&1));
c.begin_generation();
assert_eq!(
c.per_item_shaped.len(),
1,
"an empty access-set must not wipe the cache"
);
}
#[test]
fn use_old_layout_accepts_a_render_only_change_and_rejects_layout_changes() {
let c = UnifiedConstraints::default();
let red = styled(|s| {
s.color = ColorU {
r: 255,
g: 0,
b: 0,
a: 255,
};
});
let old = [text_content("hi", style())];
let new_colour = [text_content("hi", red)];
assert!(
TextShapingCache::use_old_layout(&c, &c, &old, &new_colour),
"a colour-only change must reuse the cached layout"
);
let new_text = [text_content("ho", style())];
assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &new_text));
let bigger = [text_content("hi", styled(|s| s.font_size_px = 32.0))];
assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &bigger));
let c2 = UnifiedConstraints {
available_width: AvailableSpace::Definite(100.0),
..Default::default()
};
assert!(!TextShapingCache::use_old_layout(&c, &c2, &old, &old));
}
#[test]
fn use_old_layout_on_empty_content_and_length_or_variant_mismatch() {
let c = UnifiedConstraints::default();
assert!(
TextShapingCache::use_old_layout(&c, &c, &[], &[]),
"empty vs empty is trivially reusable"
);
let one = [text_content("a", style())];
assert!(!TextShapingCache::use_old_layout(&c, &c, &[], &one));
assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &[]));
let space = [InlineContent::Space(InlineSpace {
width: 4.0,
is_breaking: true,
is_stretchy: true,
})];
assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &space));
assert!(TextShapingCache::use_old_layout(&c, &c, &space, &space));
}
#[test]
fn inline_content_layout_eq_recurses_into_ruby() {
let ruby = |base: &str| InlineContent::Ruby {
base: vec![text_content(base, style())],
text: vec![text_content("ふり", style())],
style: style(),
};
assert!(TextShapingCache::inline_content_layout_eq(
&ruby("漢"),
&ruby("漢")
));
assert!(!TextShapingCache::inline_content_layout_eq(
&ruby("漢"),
&ruby("字")
));
}
#[test]
fn calculate_id_is_deterministic_and_discriminating() {
assert_eq!(calculate_id(&"abc"), calculate_id(&"abc"));
assert_ne!(calculate_id(&"abc"), calculate_id(&"abd"));
assert_eq!(calculate_id(&0_u64), calculate_id(&0_u64));
assert_ne!(calculate_id(&0_u64), calculate_id(&u64::MAX));
let e: Vec<u8> = Vec::new();
assert_eq!(calculate_id(&e), calculate_id(&Vec::<u8>::new()));
}
#[test]
fn shaped_items_key_new_on_empty_visual_items_is_stable() {
let a = ShapedItemsKey::new(7, &[]);
let b = ShapedItemsKey::new(7, &[]);
assert_eq!(a, b);
assert_eq!(hash_of(&a), hash_of(&b));
assert_ne!(a, ShapedItemsKey::new(8, &[]));
}
#[test]
fn shaped_items_key_new_hashes_the_text_styles() {
let vi = |st: Arc<StyleProperties>| VisualItem {
logical_source: LogicalItem::Text {
source: ci(0, 0),
text: "a".to_string(),
style: st,
marker_position_outside: None,
source_node_id: None,
},
bidi_level: BidiLevel::new(0),
script: Script::Latin,
text: "a".to_string(),
run_byte_offset: 0,
};
let base = ShapedItemsKey::new(1, &[vi(style())]);
let same = ShapedItemsKey::new(1, &[vi(style())]);
let other = ShapedItemsKey::new(1, &[vi(styled(|s| s.font_size_px = 32.0))]);
assert_eq!(base, same);
assert_ne!(base.style_hash, other.style_hash, "font size must change the key");
}
#[test]
fn overflow_info_default_has_no_overflow() {
let o = OverflowInfo::default();
assert!(!o.has_overflow());
assert_eq!(o.unclipped_bounds, Rect::default());
let with = OverflowInfo {
overflow_items: vec![cl("a", 8.0)],
unclipped_bounds: Rect::default(),
};
assert!(with.has_overflow());
}
fn layout_of(items: Vec<PositionedItem>) -> UnifiedLayout {
UnifiedLayout {
items,
overflow: OverflowInfo::default(),
}
}
#[test]
fn unified_layout_empty_is_inert_across_every_accessor() {
let l = layout_of(Vec::new());
assert!(l.is_empty());
assert_eq!(l.bounds(), Rect::default());
assert_eq!(l.first_baseline(), None);
assert_eq!(l.last_baseline(), None);
assert_eq!(l.get_first_cluster_cursor(), None);
assert_eq!(l.get_last_cluster_cursor(), None);
assert!(l.grapheme_stops().is_empty());
assert_eq!(
l.hittest_cursor(LogicalPosition { x: 0.0, y: 0.0 }),
None,
"hit-testing an empty layout must return None, not index [0]"
);
assert_eq!(
l.hittest_cursor(LogicalPosition {
x: f32::NAN,
y: f32::NAN
}),
None
);
}
#[test]
fn unified_layout_bounds_spans_all_items() {
let l = layout_of(vec![
pos(cl("a", 10.0), 0.0, 0.0, 0),
pos(cl("b", 10.0), 90.0, 20.0, 1),
]);
let b = l.bounds();
assert_eq!(b.x, 0.0);
assert_eq!(b.y, 0.0);
assert_eq!(b.width, 100.0, "0 → 90+10");
approx(b.height, 36.0); assert!(!l.is_empty());
}
#[test]
fn unified_layout_baselines_skip_breaks_and_tabs() {
let l = layout_of(vec![
pos(brk(), 0.0, 0.0, 0),
pos(cl("a", 10.0), 0.0, 0.0, 0),
pos(obj(10.0, 20.0, 5.0), 10.0, 0.0, 0),
pos(tab(8.0, 16.0), 20.0, 0.0, 0),
]);
approx(
l.first_baseline().expect("the cluster, not the break"),
12.8,
);
assert_eq!(l.last_baseline(), Some(5.0), "the object, not the tab");
}
#[test]
fn unified_layout_cluster_cursors_skip_non_clusters() {
let l = layout_of(vec![
pos(brk(), 0.0, 0.0, 0),
pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
pos(tab(8.0, 16.0), 20.0, 0.0, 0),
]);
assert_eq!(
l.get_first_cluster_cursor(),
Some(TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading
})
);
assert_eq!(
l.get_last_cluster_cursor(),
Some(TextCursor {
cluster_id: gid(0, 1),
affinity: CursorAffinity::Trailing
})
);
let no_clusters = layout_of(vec![pos(brk(), 0.0, 0.0, 0)]);
assert_eq!(no_clusters.get_first_cluster_cursor(), None);
assert_eq!(no_clusters.get_last_cluster_cursor(), None);
}
#[test]
fn unified_layout_grapheme_stops_sorts_dedups_and_folds_combining_marks() {
let l = layout_of(vec![
pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0), pos(cl_at("\u{0301}", 0.0, 0, 2), 20.0, 0.0, 0), ]);
let stops = l.grapheme_stops();
assert_eq!(
stops,
vec![gid(0, 0), gid(0, 1)],
"sorted, de-duplicated, with the combining mark folded away"
);
}
#[test]
fn unified_layout_cluster_is_grapheme_continuation() {
assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{0301}"));
assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{FE0F}"), "VS-16");
assert!(!UnifiedLayout::cluster_is_grapheme_continuation("a"));
assert!(!UnifiedLayout::cluster_is_grapheme_continuation("中"));
assert!(
!UnifiedLayout::cluster_is_grapheme_continuation(""),
"an empty cluster must return false, not panic"
);
}
#[test]
fn unified_layout_grapheme_caret_offset_maps_affinity_and_gaps() {
let stops = [gid(0, 0), gid(0, 1), gid(0, 2)];
assert_eq!(
UnifiedLayout::grapheme_caret_offset(
&stops,
&TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading
}
),
Some(0)
);
assert_eq!(
UnifiedLayout::grapheme_caret_offset(
&stops,
&TextCursor {
cluster_id: gid(0, 2),
affinity: CursorAffinity::Trailing
}
),
Some(3),
"the document end is len, i.e. one past the last stop"
);
assert_eq!(
UnifiedLayout::grapheme_caret_offset(
&stops,
&TextCursor {
cluster_id: gid(0, 99),
affinity: CursorAffinity::Leading
}
),
Some(2)
);
assert_eq!(
UnifiedLayout::grapheme_caret_offset(
&[gid(5, 5)],
&TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading
}
),
None
);
assert_eq!(
UnifiedLayout::grapheme_caret_offset(
&[],
&TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading
}
),
None
);
}
#[test]
fn unified_layout_cursor_from_grapheme_offset_clamps_past_the_end() {
let stops = [gid(0, 0), gid(0, 1)];
assert_eq!(
UnifiedLayout::cursor_from_grapheme_offset(&stops, 0),
TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading
}
);
assert_eq!(
UnifiedLayout::cursor_from_grapheme_offset(&stops, 1),
TextCursor {
cluster_id: gid(0, 1),
affinity: CursorAffinity::Leading
}
);
let end = TextCursor {
cluster_id: gid(0, 1),
affinity: CursorAffinity::Trailing,
};
assert_eq!(UnifiedLayout::cursor_from_grapheme_offset(&stops, 2), end);
assert_eq!(
UnifiedLayout::cursor_from_grapheme_offset(&stops, usize::MAX),
end,
"usize::MAX must clamp, not overflow"
);
}
#[test]
#[should_panic]
fn unified_layout_cursor_from_grapheme_offset_panics_on_an_empty_stop_list() {
let _ = UnifiedLayout::cursor_from_grapheme_offset(&[], 0);
}
#[test]
fn unified_layout_cursor_motion_on_an_empty_layout_returns_the_cursor_unchanged() {
let l = layout_of(Vec::new());
let c = TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading,
};
let mut dbg = None;
assert_eq!(l.move_cursor_left(c, &mut dbg), c);
assert_eq!(l.move_cursor_right(c, &mut dbg), c);
assert_eq!(l.move_cursor_to_line_start(c, &mut dbg), c);
assert_eq!(l.move_cursor_to_line_end(c, &mut dbg), c);
assert_eq!(l.move_cursor_to_prev_word(c, &mut dbg), c);
assert_eq!(l.move_cursor_to_next_word(c, &mut dbg), c);
let mut goal = None;
assert_eq!(l.move_cursor_up(c, &mut goal, &mut dbg), c);
assert_eq!(l.move_cursor_down(c, &mut goal, &mut dbg), c);
}
#[test]
fn unified_layout_move_cursor_left_right_walk_one_grapheme_at_a_time() {
let l = layout_of(vec![
pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
pos(cl_at("c", 10.0, 0, 2), 20.0, 0.0, 0),
]);
let mut dbg = None;
let start = TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading,
};
assert_eq!(l.move_cursor_left(start, &mut dbg), start);
let c1 = l.move_cursor_right(start, &mut dbg);
assert_eq!(c1.cluster_id, gid(0, 1));
let c2 = l.move_cursor_right(c1, &mut dbg);
assert_eq!(c2.cluster_id, gid(0, 2));
let end = l.move_cursor_right(c2, &mut dbg);
assert_eq!(end.cluster_id, gid(0, 2));
assert_eq!(end.affinity, CursorAffinity::Trailing, "document end");
assert_eq!(l.move_cursor_right(end, &mut dbg), end);
assert_eq!(l.move_cursor_left(end, &mut dbg).cluster_id, gid(0, 2));
}
#[test]
fn unified_layout_hittest_cursor_picks_the_nearest_cluster_and_its_half() {
let l = layout_of(vec![
pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
]);
let hit = |x: f32| l.hittest_cursor(LogicalPosition { x, y: 5.0 }).unwrap();
assert_eq!(hit(1.0).cluster_id, gid(0, 0));
assert_eq!(hit(1.0).affinity, CursorAffinity::Leading);
assert_eq!(hit(9.0).affinity, CursorAffinity::Trailing, "right half of 'a'");
assert_eq!(hit(11.0).cluster_id, gid(0, 1));
assert_eq!(hit(-1000.0).cluster_id, gid(0, 0));
assert_eq!(hit(1000.0).cluster_id, gid(0, 1));
}
#[test]
fn unified_layout_get_selection_rects_on_an_unknown_range_is_empty() {
let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0)]);
let unknown = SelectionRange {
start: TextCursor {
cluster_id: gid(9, 9),
affinity: CursorAffinity::Leading,
},
end: TextCursor {
cluster_id: gid(9, 9),
affinity: CursorAffinity::Trailing,
},
};
assert!(l.get_selection_rects(&unknown).is_empty());
let collapsed = SelectionRange {
start: TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading,
},
end: TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading,
},
};
let _ = l.get_selection_rects(&collapsed);
}
#[test]
fn unified_layout_get_cursor_rect_for_known_and_unknown_cursors() {
let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 5.0, 7.0, 0)]);
let leading = l.get_cursor_rect(&TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading,
});
let r = leading.expect("the leading edge of a placed cluster must have a rect");
assert_eq!(r.origin.x, 5.0);
assert_eq!(r.origin.y, 7.0);
assert_eq!(r.size.width, 1.0, "the caret is a 1px sliver");
assert_eq!(
l.get_cursor_rect(&TextCursor {
cluster_id: gid(9, 0),
affinity: CursorAffinity::Leading
}),
None
);
assert_eq!(
layout_of(Vec::new()).get_cursor_rect(&TextCursor {
cluster_id: gid(0, 0),
affinity: CursorAffinity::Leading
}),
None
);
}
#[test]
fn break_cursor_new_on_an_empty_slice_is_both_at_start_and_done() {
let items: Vec<ShapedItem> = Vec::new();
let mut c = BreakCursor::new(&items);
assert!(c.is_at_start());
assert!(c.is_done());
assert_eq!(c.word_break, WordBreak::Normal);
assert_eq!(c.hyphens, Hyphens::default());
assert_eq!(c.line_break, LineBreakStrictness::default());
assert!(c.peek_next_unit().is_empty());
assert!(c.peek_next_single_item().is_empty());
assert!(c.drain_remaining().is_empty());
}
#[test]
fn break_cursor_with_word_break_stores_the_mode() {
let items = vec![cl("a", 8.0)];
let c = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
assert_eq!(c.word_break, WordBreak::BreakAll);
assert!(c.is_at_start());
assert!(!c.is_done());
}
#[test]
fn break_cursor_consume_zero_is_a_no_op() {
let items = vec![cl("a", 8.0), cl("b", 8.0)];
let mut c = BreakCursor::new(&items);
c.consume(0);
assert!(c.is_at_start());
assert_eq!(c.next_item_index, 0);
}
#[test]
fn break_cursor_consume_advances_and_ends_the_stream() {
let items = vec![cl("a", 8.0), cl("b", 8.0)];
let mut c = BreakCursor::new(&items);
c.consume(1);
assert!(!c.is_at_start());
assert!(!c.is_done());
assert_eq!(c.peek_next_single_item().len(), 1);
c.consume(1);
assert!(c.is_done());
assert!(c.peek_next_single_item().is_empty());
}
#[test]
fn break_cursor_over_consuming_past_the_end_still_reports_done() {
let items = vec![cl("a", 8.0)];
let mut c = BreakCursor::new(&items);
c.consume(usize::MAX);
assert!(c.is_done(), "an over-consume must not wrap around to not-done");
assert!(
c.drain_remaining().is_empty(),
"drain_remaining is bounds-guarded"
);
}
#[test]
#[should_panic]
fn break_cursor_peek_next_unit_after_over_consuming_slices_out_of_bounds() {
let items = vec![cl("a", 8.0)];
let mut c = BreakCursor::new(&items);
c.consume(5);
let _ = c.peek_next_unit();
}
#[test]
fn break_cursor_drains_the_remainder_before_the_main_list() {
let items = vec![cl("a", 8.0), cl("b", 8.0)];
let mut c = BreakCursor::new(&items);
c.partial_remainder = vec![cl("R", 8.0)];
assert!(!c.is_at_start(), "a pending remainder means we are mid-stream");
assert!(!c.is_done());
assert_eq!(
c.peek_next_single_item()[0].as_cluster().unwrap().text,
"R",
"the remainder is served first"
);
let drained = c.drain_remaining();
assert_eq!(drained.len(), 3, "remainder + both queued items");
assert_eq!(drained[0].as_cluster().unwrap().text, "R");
assert!(c.is_done());
}
#[test]
fn break_cursor_is_done_is_false_while_a_remainder_is_pending() {
let items: Vec<ShapedItem> = Vec::new();
let mut c = BreakCursor::new(&items);
c.partial_remainder = vec![cl("x", 8.0)];
assert!(!c.is_done(), "the main list is exhausted but the remainder is not");
c.consume(1);
assert!(c.is_done());
}
#[test]
fn break_cursor_consume_spanning_remainder_and_main_list() {
let items = vec![cl("a", 8.0), cl("b", 8.0), cl("c", 8.0)];
let mut c = BreakCursor::new(&items);
c.partial_remainder = vec![cl("R1", 8.0), cl("R2", 8.0)];
c.consume(3);
assert!(c.partial_remainder.is_empty());
assert_eq!(c.next_item_index, 1);
assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "b");
}
#[test]
fn break_cursor_peek_next_unit_returns_a_whole_word_then_the_space() {
let items = vec![
cl("h", 8.0),
cl("i", 8.0),
cl(" ", 4.0),
cl("y", 8.0),
cl("o", 8.0),
];
let mut c = BreakCursor::new(&items);
let word = c.peek_next_unit();
assert_eq!(word.len(), 2, "the word stops at the space");
assert_eq!(word[0].as_cluster().unwrap().text, "h");
c.consume(word.len());
let space = c.peek_next_unit();
assert_eq!(space.len(), 1, "a leading break opportunity is a unit on its own");
assert_eq!(space[0].as_cluster().unwrap().text, " ");
c.consume(1);
assert_eq!(c.peek_next_unit().len(), 2, "the trailing word");
}
#[test]
fn break_cursor_peek_next_unit_honours_break_all_and_keep_all() {
let items = vec![cl("中", 16.0), cl("文", 16.0), cl("字", 16.0)];
let normal = BreakCursor::new(&items);
assert_eq!(
normal.peek_next_unit().len(),
1,
"word-break:normal — each ideograph is its own unit"
);
let all = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
assert_eq!(all.peek_next_unit().len(), 1, "break-all — one cluster per unit");
let keep = BreakCursor::with_word_break(&items, WordBreak::KeepAll);
assert_eq!(
keep.peek_next_unit().len(),
3,
"keep-all — the whole CJK run is unbreakable"
);
let latin = vec![cl("a", 8.0), cl("b", 8.0)];
let latin_all = BreakCursor::with_word_break(&latin, WordBreak::BreakAll);
assert_eq!(latin_all.peek_next_unit().len(), 1, "break-all splits Latin too");
}
#[test]
fn break_cursor_peek_next_unit_glues_across_a_word_joiner() {
let plain = vec![cl("a", 8.0), cl(" ", 4.0), cl("b", 8.0)];
let control = BreakCursor::new(&plain);
assert_eq!(
control.peek_next_unit().len(),
1,
"the unit normally stops before the space"
);
let glued = vec![
cl("a", 8.0),
cl("\u{2060}", 0.0),
cl(" ", 4.0),
cl("b", 8.0),
];
let c = BreakCursor::new(&glued);
let unit = c.peek_next_unit();
assert!(
unit.len() > 1,
"a word joiner must suppress the following break, got {} item(s)",
unit.len()
);
}
#[test]
fn break_cursor_peek_next_single_item_prefers_the_remainder() {
let items = vec![cl("a", 8.0)];
let mut c = BreakCursor::new(&items);
assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "a");
c.partial_remainder = vec![cl("R", 8.0)];
assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "R");
assert_eq!(
c.peek_next_single_item().len(),
1,
"peek must never return more than one item"
);
}
fn tf(hash: u64) -> TestFont {
TestFont { hash }
}
#[test]
fn loaded_fonts_new_is_empty_and_misses_every_lookup() {
let lf: LoadedFonts<TestFont> = LoadedFonts::new();
assert!(lf.is_empty());
assert_eq!(lf.len(), 0);
assert_eq!(lf.iter().count(), 0);
assert!(lf.get(&FontId(0)).is_none());
assert!(!lf.contains_key(&FontId(u128::MAX)));
for h in [0_u64, 1, u64::MAX] {
assert!(lf.get_by_hash(h).is_none());
assert!(lf.get_font_id_by_hash(h).is_none());
assert!(!lf.contains_hash(h));
}
let d: LoadedFonts<TestFont> = LoadedFonts::default();
assert!(d.is_empty());
}
#[test]
fn loaded_fonts_insert_indexes_by_id_and_by_hash() {
let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
lf.insert(FontId(1), tf(0xDEAD));
assert_eq!(lf.len(), 1);
assert!(!lf.is_empty());
assert!(lf.contains_key(&FontId(1)));
assert!(lf.contains_hash(0xDEAD));
assert_eq!(lf.get(&FontId(1)).map(TestFont::get_hash), Some(0xDEAD));
assert_eq!(lf.get_by_hash(0xDEAD).map(TestFont::get_hash), Some(0xDEAD));
assert_eq!(lf.get_font_id_by_hash(0xDEAD), Some(&FontId(1)));
assert!(lf.get_by_hash(0).is_none());
}
#[test]
fn loaded_fonts_zero_hash_is_a_valid_key_not_a_sentinel() {
let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
lf.insert(FontId(1), tf(0));
assert!(lf.contains_hash(0), "hash 0 must be storable and findable");
assert_eq!(lf.get_by_hash(0).map(TestFont::get_hash), Some(0));
}
#[test]
fn loaded_fonts_two_ids_sharing_a_hash_keep_only_the_last_reverse_mapping() {
let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
lf.insert(FontId(1), tf(7));
lf.insert(FontId(2), tf(7));
assert_eq!(lf.len(), 2, "both fonts are stored by id");
assert_eq!(
lf.get_font_id_by_hash(7),
Some(&FontId(2)),
"the reverse index keeps only the LAST id for a colliding hash"
);
}
#[test]
fn loaded_fonts_replacing_a_font_id_leaves_a_stale_hash_mapping() {
let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
lf.insert(FontId(1), tf(100));
lf.insert(FontId(1), tf(200)); assert_eq!(lf.len(), 1, "the font map correctly holds one entry");
assert!(
lf.contains_hash(100),
"the old hash is still in the reverse index"
);
let stale = lf.get_by_hash(100).expect("stale mapping resolves");
assert_eq!(
stale.get_hash(),
200,
"looking up the OLD hash hands back the NEW font"
);
}
#[test]
fn loaded_fonts_from_iterator_matches_repeated_inserts() {
let lf: LoadedFonts<TestFont> =
vec![(FontId(1), tf(10)), (FontId(2), tf(20))].into_iter().collect();
assert_eq!(lf.len(), 2);
assert!(lf.contains_hash(10) && lf.contains_hash(20));
assert_eq!(lf.iter().count(), 2);
}
fn manager() -> FontManager<TestFont> {
FontManager::new(FcFontCache::default()).expect("FontManager::new must not fail")
}
#[test]
fn font_manager_constructors_start_empty() {
for m in [
manager(),
FontManager::from_shared(FcFontCache::default()).unwrap(),
FontManager::from_arc_shared(
FcFontCache::default(),
Arc::new(Mutex::new(HashMap::new())),
)
.unwrap(),
] {
assert!(m.get_font_chain_cache().is_empty());
assert!(m.get_loaded_fonts().is_empty());
assert!(m.get_loaded_font_ids().is_empty());
assert!(m.registry.is_none());
assert_eq!(m.last_resolved_font_stacks_sig, None);
assert!(m.get_font_by_hash(0).is_none());
assert!(m.get_embedded_font_by_hash(u64::MAX).is_none());
}
}
#[test]
fn font_manager_from_arc_shared_sees_writes_through_the_shared_pool() {
let pool: Arc<Mutex<HashMap<FontId, TestFont>>> = Arc::new(Mutex::new(HashMap::new()));
let a = FontManager::from_arc_shared(FcFontCache::default(), pool.clone()).unwrap();
let b = FontManager::from_arc_shared(FcFontCache::default(), pool).unwrap();
assert!(a.insert_font(FontId(1), tf(5)).is_none(), "no previous font");
assert_eq!(
b.get_loaded_fonts().len(),
1,
"the second manager must observe the first's insert"
);
assert_eq!(b.get_font_by_hash(5).map(|f| f.get_hash()), Some(5));
assert!(Arc::ptr_eq(&a.shared_parsed_fonts(), &b.shared_parsed_fonts()));
}
#[test]
fn font_manager_insert_font_returns_the_replaced_font() {
let m = manager();
assert!(m.insert_font(FontId(1), tf(1)).is_none());
let old = m.insert_font(FontId(1), tf(2)).expect("must return the old font");
assert_eq!(old.get_hash(), 1);
assert_eq!(m.get_loaded_fonts().len(), 1);
}
#[test]
fn font_manager_insert_fonts_and_remove_font() {
let m = manager();
m.insert_fonts(vec![(FontId(1), tf(1)), (FontId(2), tf(2))]);
assert_eq!(m.get_loaded_font_ids().len(), 2);
assert_eq!(m.remove_font(&FontId(1)).map(|f| f.get_hash()), Some(1));
assert!(m.remove_font(&FontId(1)).is_none(), "double-remove is a no-op");
assert!(
m.remove_font(&FontId(u128::MAX)).is_none(),
"removing an unknown id must not panic"
);
assert_eq!(m.get_loaded_fonts().len(), 1);
m.insert_fonts(Vec::new());
assert_eq!(m.get_loaded_fonts().len(), 1);
}
#[test]
fn font_manager_get_font_by_hash_scans_linearly_and_misses_cleanly() {
let m = manager();
m.insert_fonts(vec![(FontId(1), tf(11)), (FontId(2), tf(22))]);
assert_eq!(m.get_font_by_hash(22).map(|f| f.get_hash()), Some(22));
assert!(m.get_font_by_hash(33).is_none());
assert!(m.get_font_by_hash(u64::MAX).is_none());
assert!(m.get_font_by_hash(0).is_none());
}
#[test]
fn font_manager_chain_cache_set_merge_and_signature() {
let mut m = manager();
assert!(m.get_font_chain_cache().is_empty());
m.set_font_chain_cache_with_sig(HashMap::new(), Some(42));
assert_eq!(m.last_resolved_font_stacks_sig, Some(42));
m.set_font_chain_cache(HashMap::new());
assert_eq!(
m.last_resolved_font_stacks_sig, None,
"a signature-less set must invalidate the recorded signature"
);
m.merge_font_chain_cache(HashMap::new());
assert!(m.get_font_chain_cache().is_empty());
}
#[test]
fn font_manager_garbage_collect_evicts_everything_not_in_the_keep_set() {
let mut m = manager();
m.insert_fonts(vec![
(FontId(1), tf(1)),
(FontId(2), tf(2)),
(FontId(3), tf(3)),
]);
let mut keep = HashSet::new();
keep.insert(FontId(2));
let evicted = m.garbage_collect_fonts(&keep, &HashSet::new());
assert_eq!(evicted, 2);
assert_eq!(m.get_loaded_font_ids(), keep);
assert_eq!(m.garbage_collect_fonts(&keep, &HashSet::new()), 0);
assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 1);
assert!(m.get_loaded_fonts().is_empty());
assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 0);
}
#[test]
fn font_manager_load_missing_for_chains_with_no_chains_loads_nothing() {
use crate::solver3::getters::ResolvedFontChains;
let m = manager();
let empty = ResolvedFontChains {
chains: HashMap::new(),
..Default::default()
};
let failed = m.load_missing_for_chains(
&empty,
|_bytes, _idx| -> Result<TestFont, LayoutError> {
panic!("the loader must never be invoked when there is nothing to load")
},
);
assert!(failed.is_empty());
assert!(m.get_loaded_fonts().is_empty());
}
#[test]
fn font_context_from_fc_cache_starts_empty_and_converts_to_a_manager() {
let ctx = FontContext::from_fc_cache(FcFontCache::default());
assert!(ctx.font_chain_cache.is_empty());
assert!(ctx.embedded_fonts.is_empty());
assert!(ctx.font_hash_to_families.is_empty());
assert!(ctx.registry.is_none());
assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
ctx.load_fonts_for_chains();
assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
let mgr = ctx.to_font_manager();
assert!(mgr.get_font_chain_cache().is_empty());
assert!(mgr.registry.is_none());
assert_eq!(mgr.last_resolved_font_stacks_sig, None);
assert!(
Arc::ptr_eq(&mgr.parsed_fonts, &ctx.parsed_fonts),
"the manager must share (not copy) the parsed-font pool"
);
}
#[test]
fn create_logical_items_on_empty_and_whitespace_only_content() {
let mut dbg = None;
assert!(create_logical_items(&[], &[], &mut dbg).is_empty());
let empty_run = [text_content("", style())];
assert!(create_logical_items(&empty_run, &[], &mut dbg).is_empty());
let ws = [text_content(" \t\n", style())];
assert!(!create_logical_items(&ws, &[], &mut dbg).is_empty());
}
#[test]
fn create_logical_items_handles_multibyte_and_astral_text_without_panicking() {
let mut dbg = None;
for s in ["\u{1F600}", "é\u{0301}", "中文", "a\u{200B}b", "\u{FEFF}"] {
let content = [text_content(s, style())];
let items = create_logical_items(&content, &[], &mut dbg);
assert!(!items.is_empty(), "{s:?} produced no logical items");
}
}
#[test]
fn create_logical_items_on_a_long_run_does_not_hang() {
let mut dbg = None;
let long = "a".repeat(100_000);
let content = [text_content(&long, style())];
let items = create_logical_items(&content, &[], &mut dbg);
assert!(!items.is_empty());
}
#[test]
fn create_logical_items_debug_messages_are_recorded_when_requested() {
let mut dbg = Some(Vec::new());
let content = [text_content("hi", style())];
let _ = create_logical_items(&content, &[], &mut dbg);
assert!(
!dbg.expect("Some(..) in → Some(..) out").is_empty(),
"the debug sink must be populated when it is Some"
);
}
#[test]
fn get_base_direction_from_logical_defaults_to_ltr_on_empty_input() {
assert_eq!(get_base_direction_from_logical(&[]), BidiDirection::Ltr);
let mut dbg = None;
let ltr = create_logical_items(&[text_content("hello", style())], &[], &mut dbg);
assert_eq!(get_base_direction_from_logical(<r), BidiDirection::Ltr);
let rtl = create_logical_items(&[text_content("\u{05D0}\u{05D1}", style())], &[], &mut dbg);
assert_eq!(get_base_direction_from_logical(&rtl), BidiDirection::Rtl);
}
#[test]
fn reorder_logical_items_on_empty_input_is_ok_and_empty() {
let mut dbg = None;
let out = reorder_logical_items(&[], BidiDirection::Ltr, UnicodeBidi::Normal, &mut dbg)
.expect("reordering nothing must succeed");
assert!(out.is_empty());
}
#[test]
fn reorder_logical_items_preserves_content_for_pure_ltr_text() {
let mut dbg = None;
let logical = create_logical_items(&[text_content("abc", style())], &[], &mut dbg);
let visual = reorder_logical_items(
&logical,
BidiDirection::Ltr,
UnicodeBidi::Normal,
&mut dbg,
)
.expect("LTR reordering must succeed");
let joined: String = visual.iter().map(|v| v.text.as_str()).collect();
assert_eq!(joined, "abc", "pure LTR text must survive bidi unchanged");
assert!(visual.iter().all(|v| !v.bidi_level.is_rtl()));
}
#[cfg(not(feature = "text_layout_hyphenation"))]
#[test]
fn stub_hyphenate_never_reports_a_break() {
let s = Standard;
assert!(s.hyphenate("").breaks.is_empty());
assert!(s.hyphenate("hyphenation").breaks.is_empty());
assert!(s.hyphenate(&"a".repeat(10_000)).breaks.is_empty());
assert!(s.hyphenate("\u{1F600}\u{0301}").breaks.is_empty());
}
#[cfg(not(feature = "text_layout_hyphenation"))]
#[test]
fn stub_get_hyphenator_always_errors() {
assert!(matches!(
get_hyphenator(Language::EnglishUS),
Err(LayoutError::HyphenationError(_))
));
}
#[cfg(feature = "text_layout_hyphenation")]
#[test]
fn get_hyphenator_loads_an_embedded_language_and_hyphenates() {
let h =
get_hyphenator(HyphenationLanguage::EnglishUS).expect("en-US dictionaries are embedded");
let empty = h.hyphenate("");
assert!(empty.breaks.is_empty(), "an empty word must not panic");
let one = h.hyphenate("a");
assert!(one.breaks.is_empty());
let word = "hyphenation";
let opps = h.hyphenate(word);
for &b in &opps.breaks {
assert!(b > 0 && b < word.len(), "break {b} is outside {word:?}");
assert!(word.is_char_boundary(b), "break {b} splits a char");
}
let weird = h.hyphenate("\u{1F600}\u{0301}");
assert!(weird.breaks.len() < 8, "no runaway break list");
}
}