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, 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(),
};
fm.register_builtin_mock_fonts();
fm
}
}
#[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, rust_fontconfig::FontMatch>,
}
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(),
};
fm.register_builtin_mock_fonts();
Ok(fm)
}
pub fn register_named_font(
&mut self,
family: &str,
bytes: &[u8],
coverage: Vec<rust_fontconfig::UnicodeRange>,
) -> FontId {
let norm = rust_fontconfig::utils::normalize_family_name(family);
let mut existing: Vec<(FontId, Vec<rust_fontconfig::UnicodeRange>)> = Vec::new();
self.fc_cache.for_each_pattern(|pattern, id| {
let hit = pattern
.family
.as_deref()
.is_some_and(|f| rust_fontconfig::utils::normalize_family_name(f) == norm);
if hit {
existing.push((*id, pattern.unicode_ranges.clone()));
}
});
if let Some((id, ranges)) = existing
.into_iter()
.find(|(id, _)| self.fc_cache.is_memory_font(id))
{
self.memory_families.insert(
norm,
rust_fontconfig::FontMatch {
id,
unicode_ranges: ranges,
fallbacks: Vec::new(),
},
);
return id;
}
let pattern = rust_fontconfig::FcPattern {
name: Some(family.to_string()),
family: Some(family.to_string()),
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.memory_families.insert(
norm,
rust_fontconfig::FontMatch {
id,
unicode_ranges: coverage,
fallbacks: Vec::new(),
},
);
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(),
};
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 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,
}
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(),
}
}
}
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.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)
}
const 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,
},
_ => 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
)));
}
'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;
#[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;
}
}
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;
}
line_top_y += line_height.max(fragment_constraints.resolved_line_height());
line_index += 1;
positioned_items.extend(line_pos_items);
}
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
}
}
#[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());
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)]
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");
}
}