//! Real inline text layout via cosmic-text (HarfBuzz-class shaping through //! rustybuzz + UAX#14 line breaking), replacing the earlier approximation //! that split each text node into one taffy flex item per word. //! //! The model matches how real browsers separate formatting contexts: taffy //! lays out the *block/flex/grid* boxes, and an *inline formatting context* //! (a box whose children are all inline-level text/spans) collapses to a //! single taffy leaf whose measure function line-breaks its shaped text at //! whatever width taffy offers. Line wrapping, alignment, and intrinsic //! sizing then come from a real text engine instead of flexbox tricks. //! //! Fonts are loaded from embedded bytes only, never the OS, so layout is //! byte-for-byte deterministic across hosts (the whole engine's guarantee). use std::{collections::HashMap, sync::Arc}; use cosmic_text::{ Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FeatureTag, FontFeatures, FontSystem, FontVariations, Metrics, Shaping, Style, SwashCache, SwashImage, VariationTag, Weight, Wrap, }; use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith}; use swash::zeno::{Angle, Format, Transform, Vector}; use obscura_dom::tree::{DomTree, NodeId}; use crate::{Dimension, Display, LayoutStyle, Rect, TextTransform}; // Bundled faces. Chrome on this class of host renders `sans-serif` and the // ubiquitous Arial/Helvetica/system-ui stacks as Liberation Sans, `serif` as // Liberation Serif, and `monospace` as Liberation Mono. Matching those keeps // text metrics (advance widths, wrapping, line positions) aligned with Chromium // instead of drifting ~12% wider on DejaVu's wider glyphs. DejaVu Sans is kept // only as a last-resort fallback for glyphs the Liberation faces lack. static SANS_R: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); static SANS_B: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf"); static SANS_O: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); static SANS_BO: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); static SERIF_R: &[u8] = include_bytes!("../assets/liberation-serif.ttf"); static SERIF_B: &[u8] = include_bytes!("../assets/liberation-serif-bold.ttf"); static SERIF_O: &[u8] = include_bytes!("../assets/liberation-serif-oblique.ttf"); static SERIF_BO: &[u8] = include_bytes!("../assets/liberation-serif-boldoblique.ttf"); static MONO_R: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); static MONO_B: &[u8] = include_bytes!("../assets/liberation-mono-bold.ttf"); static MONO_O: &[u8] = include_bytes!("../assets/liberation-mono-oblique.ttf"); static MONO_BO: &[u8] = include_bytes!("../assets/liberation-mono-boldoblique.ttf"); static FALLBACK: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); const FAMILY: &str = "Liberation Sans"; const SERIF_FAMILY: &str = "Liberation Serif"; const MONO_FAMILY: &str = "Liberation Mono"; /// Map a CSS `font-family` list to a bundled face the way Chromium resolves the /// generic families on this host: a monospace/code stack -> Liberation Mono, a /// serif stack -> Liberation Serif, everything else (sans-serif, Arial, /// Helvetica, system-ui, named sans webfonts, ...) -> Liberation Sans. The first /// family whose category is recognizable wins, matching CSS fallback order. fn resolve_font_family(fam: Option<&str>) -> &'static str { let Some(f) = fam else { return FAMILY }; for tok in f.split(',') { let t = tok.trim().trim_matches(|c| c == '"' || c == '\'').trim(); if t.is_empty() { continue; } if t == "monospace" || t.contains("mono") || t.contains("courier") || t.contains("consol") || t == "menlo" || t == "monaco" || t == "code" { return MONO_FAMILY; } if t == "serif" || t == "georgia" || t.contains("times") || t == "cambria" || t.contains("garamond") || t.contains("liberation serif") || t == "roman" { return SERIF_FAMILY; } if t == "sans-serif" || t.contains("sans") || t == "arial" || t == "helvetica" || t == "helvetica neue" || t == "system-ui" || t == "-apple-system" || t == "roboto" || t == "segoe ui" || t == "inter" || t == "verdana" || t == "tahoma" || t == "ui-sans-serif" { return FAMILY; } // Unrecognized named webfont: keep scanning for a generic fallback. } FAMILY } #[derive(Clone)] struct LoadedFamily { faces: Vec, } #[derive(Clone)] struct LoadedFace { name: Arc, font_id: Option, metrics: FaceMetrics, min_weight: u16, max_weight: u16, italic: bool, } #[derive(Clone, Copy, Debug, PartialEq)] struct FaceMetrics { ascent: f32, descent: f32, line_gap: f32, units_per_em: f32, } #[derive(Clone)] struct ResolvedFont { family: Arc, font_id: Option, metrics: FaceMetrics, } #[derive(Clone)] pub(crate) struct WebFont { pub data: Vec, pub family: Option, pub weight: Option<(u16, u16)>, pub italic: Option, } fn resolve_loaded_font( fam: Option<&str>, requested_weight: u16, requested_italic: bool, loaded: &HashMap, ) -> ResolvedFont { if let Some(stack) = fam { for token in stack.split(',') { let name = token .trim() .trim_matches(|c| c == '"' || c == '\'') .trim(); if let Some(family) = loaded.get(&name.to_ascii_lowercase()) { let exact_style: Vec<_> = family .faces .iter() .filter(|face| face.italic == requested_italic) .collect(); let candidates: Vec<_> = if exact_style.is_empty() { family.faces.iter().collect() } else { exact_style }; if let Some(face) = candidates .iter() .copied() .find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight)) { // The named-family matcher uses fontdb's default weight for // this resource, while a variable face commonly advertises // `100 900` in CSS. Preserve the descriptor-selected file // and its database weight; the authored coordinate enters // the canonical axis tuple below. return ResolvedFont { family: Arc::clone(&face.name), font_id: face.font_id, metrics: face.metrics, }; } let available: Vec<_> = candidates.iter().map(|face| face.min_weight).collect(); let matched = match_font_weight(requested_weight, &available); if let Some(face) = candidates .into_iter() .find(|face| face.min_weight == matched) { return ResolvedFont { family: Arc::clone(&face.name), font_id: face.font_id, metrics: face.metrics, }; } } } } let fallback = resolve_font_family(fam); ResolvedFont { family: Arc::from(fallback), font_id: None, metrics: bundled_face_metrics(fallback), } } /// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects /// 700 (not 400) when a family only provides regular and bold faces. fn match_font_weight(requested: u16, available: &[u16]) -> u16 { if available.contains(&requested) { return requested; } let mut weights = available.to_vec(); weights.sort_unstable(); weights.dedup(); if weights.is_empty() { return requested; } if (400..=500).contains(&requested) { weights .iter() .copied() .filter(|weight| *weight >= requested && *weight <= 500) .min() .or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max()) .or_else(|| weights.iter().copied().filter(|weight| *weight > 500).min()) .unwrap_or(requested) } else if requested < 400 { weights .iter() .copied() .filter(|weight| *weight <= requested) .max() .or_else(|| weights.iter().copied().filter(|weight| *weight > requested).min()) .unwrap_or(requested) } else { weights .iter() .copied() .filter(|weight| *weight >= requested) .min() .or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max()) .unwrap_or(requested) } } /// Resolve `line-height: normal` from the selected face's horizontal header. /// /// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and /// line gap independently before adding them. Multiplying their sum by the /// font size (or rounding the final line height) is observably different at /// fractional and small sizes: Liberation Sans at 9.333px is 10px in /// Chromium, not 11px. Keep these metrics beside the embedded faces so normal /// line boxes follow the same device-pixel rhythm without consulting host /// fonts. fn bundled_face_metrics(family: &str) -> FaceMetrics { let (ascent, descent, line_gap) = match family { SERIF_FAMILY => (1825.0, 443.0, 87.0), MONO_FAMILY => (1705.0, 615.0, 0.0), _ => (1854.0, 434.0, 67.0), }; FaceMetrics { ascent, descent, line_gap, units_per_em: 2048.0, } } fn normal_line_height(font_size: f32, metrics: FaceMetrics) -> f32 { let scale = font_size / metrics.units_per_em.max(1.0); (metrics.ascent * scale).round() + (metrics.descent * scale).round() + (metrics.line_gap * scale).round() } fn font_metrics( db: &cosmic_text::fontdb::Database, id: cosmic_text::fontdb::ID, ) -> Option { db.with_face_data(id, |data, face_index| { let face = cosmic_text::ttf_parser::Face::parse(data, face_index).ok()?; Some(FaceMetrics { ascent: face.ascender().max(0) as f32, descent: -(face.descender().min(0) as f32), line_gap: face.line_gap().max(0) as f32, units_per_em: face.units_per_em() as f32, }) }) .flatten() } fn used_line_height_for_font(style: &LayoutStyle, font: &ResolvedFont) -> f32 { used_line_height_with_metrics(style, font.metrics) } fn used_line_height_with_metrics(style: &LayoutStyle, metrics: FaceMetrics) -> f32 { let font_size = style.font_size.unwrap_or(16.0); match style.line_height { Some(crate::LineHeight::Px(px)) => px, Some(crate::LineHeight::Ratio(ratio)) => font_size * ratio, Some(crate::LineHeight::Relative(relative)) => match relative { crate::Dimension::Percent(percent) => font_size * percent, dimension => match dimension.resolve(font_size, 16.0, 0.0, 0.0) { crate::Dimension::Px(px) => px, _ => font_size, }, }, None | Some(crate::LineHeight::Normal) => normal_line_height(font_size, metrics), } } /// Computed used line-height shared by shaped inline runs and forced-break /// sentinels that cannot join a run. pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 { let family = resolve_font_family(style.font_family.as_deref()); used_line_height_with_metrics(style, bundled_face_metrics(family)) } type ClipTextFill = (f32, Vec<([u8; 4], Option)>); /// Per-glyph flags carried through cosmic-text's metadata. The common /// non-variable path stays allocation-free: underline and fill remain packed /// directly, while the upper half is a one-based index into an optional /// variation-set table. const META_UNDERLINE: usize = 1; const META_FILL_SHIFT: usize = 1; const META_VARIATION_BITS: usize = usize::BITS as usize / 2; const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS; const META_VARIATION_MASK: usize = ((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT; const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE; fn metadata_fill(metadata: usize) -> Option { ((metadata & META_FILL_MASK) >> META_FILL_SHIFT).checked_sub(1) } fn metadata_variation(metadata: usize) -> Option { ((metadata & META_VARIATION_MASK) >> META_VARIATION_SHIFT).checked_sub(1) } /// One inline formatting context: a shaped cosmic-text buffer plus where to /// paint it (filled in after layout). pub struct InlineItem { buffer: Buffer, /// Unmodified shaped content retained only when `text-indent` is nonzero. /// Each Taffy measurement can then derive a first-line-only wrap boundary /// without accumulating synthetic hard breaks across repeated probes. source_buffer: Option, text_indent: Dimension, /// Used LTR paint offset for the first formatted line at the most recently /// shaped width. Later lines retain the ordinary content-box origin. first_line_offset: f32, /// Whether final shaping should tighten the wrap width while preserving /// the natural line count (`text-wrap-style: balance`). balance_wrap: bool, /// Alignment is applied against the original content width. Cosmic-text /// uses its buffer width for both wrapping and alignment, so a balanced /// (narrower) buffer needs a corresponding origin inset. align: Option, /// Minimum block-size contributed by explicit `
` breaks. cosmic-text /// omits the final empty run for a trailing newline, while CSS still gives /// a break-only or consecutive-break line the parent's used line-height. forced_min_height: f32, /// Content-box top-left in viewport coordinates, set by `finalize`. origin: (f32, f32), /// Ancestor `overflow: hidden` clip, set by `finalize`. clip: Option, /// Per-span `-webkit-background-clip: text` fills. Glyph metadata selects /// one entry, allowing an inline accent span to own a gradient without /// recoloring the rest of its heading. clip_fills: Vec, /// Canonical axis tuples, populated only when this IFC actually contains /// variable text. Glyph metadata stores a one-based index. variation_sets: Vec>, } /// Owns the font set and shaping caches for one render pass, plus every /// inline formatting context discovered while building the tree. Lives in /// [`crate::DomLayout`] so paint can rasterize the shaped glyphs. pub struct TextEngine { font_system: FontSystem, loaded_families: HashMap, swash: SwashCache, variable_swash: VariableSwashCache, items: Vec, replaced: Vec, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct VariableCacheKey { glyph: CacheKey, variations: Arc, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct VariationIntentKey { font_id: cosmic_text::fontdb::ID, weight_bits: Option, optical_size_bits: Option, italic: bool, explicit: Option>, } /// Swash's ordinary cache key intentionally has no variation coordinates. /// Keep variable instances in a separate cache so different axis tuples can /// never share an outline and repeated paint remains O(1) after first raster. struct VariableSwashCache { context: ScaleContext, images: HashMap>, /// Canonical coordinates supported by the face actually selected for a /// glyph. This is deliberately separate from authored intent: unsupported /// axes and values that clamp to the same endpoint share raster entries. instances: HashMap>>, } impl VariableSwashCache { fn new() -> Self { Self { context: ScaleContext::new(), images: HashMap::new(), instances: HashMap::new(), } } fn effective_variations( &mut self, font_system: &mut FontSystem, font_id: cosmic_text::fontdb::ID, weight: Option, optical_size: Option, italic: bool, explicit: Option>, ) -> Option> { let key = VariationIntentKey { font_id, weight_bits: weight .filter(|value| value.is_finite()) .map(|value| (value + 0.0).to_bits()), optical_size_bits: optical_size .filter(|value| value.is_finite()) .map(|value| (value + 0.0).to_bits()), italic, explicit, }; if let Some(cached) = self.instances.get(&key) { return cached.clone(); } let resolved = font_system.get_font(font_id).and_then(|font| { let swash_font = font.as_swash(); let has_ital = swash_font .variations() .any(|axis| axis.tag() == swash::tag_from_bytes(b"ital")); let mut variations = FontVariations::new(); for axis in swash_font.variations() { let tag = axis.tag(); let explicit_value = key.explicit.as_ref().and_then(|settings| { settings .iter() .find(|setting| { swash::tag_from_bytes(setting.tag.as_bytes()) == tag }) .map(|setting| setting.value.0) }); let automatic_value = if tag == swash::tag_from_bytes(b"wght") { key.weight_bits.map(f32::from_bits) } else if tag == swash::tag_from_bytes(b"opsz") { key.optical_size_bits.map(f32::from_bits) } else if italic && tag == swash::tag_from_bytes(b"ital") { Some(1.0) } else if italic && !has_ital && tag == swash::tag_from_bytes(b"slnt") { Some(-14.0) } else { None }; let Some(value) = explicit_value.or(automatic_value) else { continue; }; if !value.is_finite() { continue; } let value = value.clamp(axis.min_value(), axis.max_value()) + 0.0; variations.set(VariationTag::new(&tag.to_be_bytes()), value); } (!variations.is_empty()).then(|| Arc::new(variations)) }); self.instances.insert(key, resolved.clone()); resolved } fn with_pixels( &mut self, font_system: &mut FontSystem, cache_key: CacheKey, variations: Arc, base: Color, mut f: F, ) { let render_variations = Arc::clone(&variations); let key = VariableCacheKey { glyph: cache_key, variations, }; let image = self.images.entry(key).or_insert_with(|| { let font = font_system.get_font(cache_key.font_id)?; let settings = render_variations.iter().map(|variation| { ( swash::tag_from_bytes(variation.tag.as_bytes()), variation.value.0, ) }); let mut scaler = self .context .builder(font.as_swash()) .size(f32::from_bits(cache_key.font_size_bits)) .hint(true) .variations(settings) .build(); let offset = Vector::new(cache_key.x_bin.as_float(), cache_key.y_bin.as_float()); Render::new(&[ Source::ColorOutline(0), Source::ColorBitmap(StrikeWith::BestFit), Source::Outline, ]) .format(Format::Alpha) .offset(offset) .transform( cache_key .flags .contains(CacheKeyFlags::FAKE_ITALIC) .then(|| { Transform::skew( Angle::from_degrees(14.0), Angle::from_degrees(0.0), ) }), ) .render(&mut scaler, cache_key.glyph_id) }); let Some(image) = image else { return }; let left = image.placement.left; let top = -image.placement.top; match image.content { SwashContent::Mask => { for (index, alpha) in image.data.iter().copied().enumerate() { let x = index as i32 % image.placement.width as i32; let y = index as i32 / image.placement.width as i32; f( left + x, top + y, Color(((alpha as u32) << 24) | base.0 & 0x00FF_FFFF), ); } } SwashContent::Color => { for (index, rgba) in image.data.chunks_exact(4).enumerate() { let x = index as i32 % image.placement.width as i32; let y = index as i32 / image.placement.width as i32; f( left + x, top + y, Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]), ); } } SwashContent::SubpixelMask => {} } } } const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1); #[derive(Clone, Copy)] struct ReplacedItem { intrinsic_width: f32, preferred_width: Option, preferred_height: Option, preferred_ratio: f32, min_width: Option, min_height: Option, max_width: Option, max_height: Option, /// CSS Sizing's cyclic-percentage rule makes a proper replaced element's /// inline min-content contribution zero when its preferred or maximum /// inline size contains a percentage. The natural size still participates /// in max-content sizing and in the final definite layout. zero_inline_min_content: bool, } impl ReplacedItem { fn from_style(width: f32, height: f32, style: &LayoutStyle) -> Self { let px = |dimension| match dimension { Dimension::Px(value) => Some(value.max(0.0)), _ => None, }; let expression_has_percentage = |index: usize| { style.size_expressions[index] .as_deref() .map_or(false, |expression| expression.contains('%')) }; let intrinsic_ratio = if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { width / height } else { 1.0 }; ReplacedItem { intrinsic_width: width, preferred_width: px(style.width), preferred_height: px(style.height), preferred_ratio: style .aspect_ratio .filter(|ratio| ratio.is_finite() && *ratio > 0.0) .unwrap_or(intrinsic_ratio), min_width: px(style.min_width), min_height: px(style.min_height), max_width: px(style.max_width), max_height: px(style.max_height), zero_inline_min_content: matches!(style.width, Dimension::Percent(_)) || matches!(style.max_width, Dimension::Percent(_)) || expression_has_percentage(0) || expression_has_percentage(4), } } fn clamp(value: f32, min: Option, max: Option) -> f32 { // CSS sizing gives the minimum precedence when min > max. let value = max.map_or(value, |max| value.min(max)); min.map_or(value, |min| value.max(min)) } /// Apply the CSS 2.1 10.4/10.7 constraint table for a replaced element /// whose preferred width and height are both auto. Unlike independently /// clamping the two axes, this transfers a one-axis min/max constraint /// through the preferred aspect ratio whenever the constraints allow it. fn constrain_auto_size(self, tentative: taffy::Size) -> taffy::Size { let min_width = self.min_width.unwrap_or(0.0); let min_height = self.min_height.unwrap_or(0.0); let max_width = self.max_width.unwrap_or(f32::INFINITY).max(min_width); let max_height = self.max_height.unwrap_or(f32::INFINITY).max(min_height); let width = tentative.width; let height = tentative.height; let height_at_max_width = (max_width / self.preferred_ratio).max(min_height); let height_at_min_width = (min_width / self.preferred_ratio).min(max_height); let width_at_max_height = (max_height * self.preferred_ratio).max(min_width); let width_at_min_height = (min_height * self.preferred_ratio).min(max_width); let (width, height) = if width > max_width { if height > max_height { if max_width * height <= max_height * width { (max_width, height_at_max_width) } else { (width_at_max_height, max_height) } } else { (max_width, height_at_max_width) } } else if width < min_width { if height < min_height { if min_width * height <= min_height * width { (width_at_min_height, min_height) } else { (min_width, height_at_min_width) } } else { (min_width, height_at_min_width) } } else if height > max_height { (width_at_max_height, max_height) } else if height < min_height { (width_at_min_height, min_height) } else { (width, height) }; taffy::Size { width, height } } fn size(self, known: taffy::Size>) -> taffy::Size { let (width, height) = match (known.width, known.height) { (Some(width), Some(height)) => (width, height), (Some(width), None) => ( width, width / self.preferred_ratio, ), (None, Some(height)) => ( height * self.preferred_ratio, height, ), (None, None) => match (self.preferred_width, self.preferred_height) { (Some(width), Some(height)) => (width, height), (Some(width), None) => (width, width / self.preferred_ratio), (None, Some(height)) => (height * self.preferred_ratio, height), (None, None) => ( self.intrinsic_width, self.intrinsic_width / self.preferred_ratio, ), }, }; let tentative = taffy::Size { width, height }; if self.preferred_width.is_none() && self.preferred_height.is_none() && (known.width.is_none() || known.height.is_none()) { self.constrain_auto_size(tentative) } else { taffy::Size { width: Self::clamp(width, self.min_width, self.max_width), height: Self::clamp(height, self.min_height, self.max_height), } } } } pub(crate) fn constrained_auto_replaced_size( width: f32, height: f32, style: &LayoutStyle, ) -> taffy::Size { ReplacedItem::from_style(width, height, style).size(taffy::Size { width: None, height: None, }) } impl Default for TextEngine { fn default() -> Self { Self::new() } } impl TextEngine { pub fn new() -> Self { Self::new_with_fonts(&[]) } pub fn new_with_fonts(fonts: &[Vec]) -> Self { let fonts: Vec<_> = fonts .iter() .map(|data| WebFont { data: data.clone(), family: None, weight: None, italic: None, }) .collect(); Self::new_with_web_fonts(&fonts) } pub(crate) fn new_with_web_fonts(fonts: &[WebFont]) -> Self { // Build a database from embedded and page-provided faces. Never call // load_system_fonts: a host's font set would make layout differ // machine to machine and add a multi-millisecond startup scan. let mut db = cosmic_text::fontdb::Database::new(); let mut declarations = Vec::new(); for bytes in [ SANS_R, SANS_B, SANS_O, SANS_BO, SERIF_R, SERIF_B, SERIF_O, SERIF_BO, MONO_R, MONO_B, MONO_O, MONO_BO, FALLBACK, ] { for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))) { declarations.push((id, None, None, None)); } } for font in fonts { for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new( font.data.clone(), ))) { declarations.push(( id, font.family.clone(), font.weight, font.italic, )); } } let mut loaded_families = HashMap::new(); for (id, declared_family, declared_weight, declared_italic) in declarations { let Some(face) = db.face(id) else { continue }; let names = face.families.clone(); let internal_name = names .first() .map(|(name, _)| Arc::::from(name.as_str())) .unwrap_or_else(|| Arc::from(FAMILY)); let shape_weight = face.weight.0; let metrics = font_metrics(&db, id) .unwrap_or_else(|| bundled_face_metrics(internal_name.as_ref())); let italic = declared_italic .unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal)); let weight = declared_weight.unwrap_or((shape_weight, shape_weight)); let declared_names: Vec = declared_family .map(|name| vec![name]) .unwrap_or_else(|| names.into_iter().map(|(name, _)| name).collect()); for name in declared_names { let family = loaded_families .entry(name.to_ascii_lowercase()) .or_insert_with(|| LoadedFamily { faces: Vec::new() }); family.faces.push(LoadedFace { name: Arc::clone(&internal_name), font_id: Some(id), metrics, min_weight: weight.0, max_weight: weight.1, italic, }); } } db.set_sans_serif_family(FAMILY); let font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db); TextEngine { font_system, loaded_families, swash: SwashCache::new(), variable_swash: VariableSwashCache::new(), items: Vec::new(), replaced: Vec::new(), } } /// Number of inline formatting contexts collected (for debug/stats). pub fn len(&self) -> usize { self.items.len() } pub fn is_empty(&self) -> bool { self.items.is_empty() } #[cfg(test)] pub(crate) fn item_text(&self, idx: usize) -> String { self.items[idx] .buffer .lines .iter() .map(|line| line.text()) .collect() } /// Build an inline formatting context for `id`'s subtree if it is one we /// can collapse to shaped text (see [`is_pure_text_ifc`]); returns the /// item index to store as the leaf's taffy context. `None` means the /// container is not a pure-text IFC and should build through the normal /// (block / flex / word-split) path. pub fn try_build(&mut self, tree: &DomTree, id: NodeId, styles: &std::collections::HashMap) -> Option { if !is_pure_text_ifc(tree, id, styles) { return None; } let base = styles.get(&id)?; let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() }; let font = resolve_loaded_font( base.font_family.as_deref(), crate::style::used_font_weight(base), base.font_style_italic.unwrap_or(false), &self.loaded_families, ); let ctx = base_span_ctx(base, font, &mut collector); let line_height = ctx.line_height; let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); collect_spans( tree, id, styles, ctx, &mut spans, &mut collector, &self.loaded_families, ); self.push_shaped_item(base, line_height, spans, collector.clip_fills) } /// Build an inline formatting context from a *run* of consecutive /// inline-level siblings inside `parent` (a mixed-content block whose /// other children are block-level). The run folds to one shaped buffer /// exactly like a whole-container IFC, using the parent's style as the /// base. Returns `None` when any node in the run cannot fold (atomic /// inline, replaced element, ...) or the run has no visible text; the /// caller then falls back to the flex-wrap wrapper for that run. pub fn try_build_run( &mut self, tree: &DomTree, parent: NodeId, run: &[NodeId], styles: &std::collections::HashMap, ) -> Option { let mut has_text = false; for &cid in run { if !inline_child_ok(tree, cid, styles, &mut has_text) { return None; } } if !has_text { return None; } let base = styles.get(&parent)?; let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() }; let font = resolve_loaded_font( base.font_family.as_deref(), crate::style::used_font_weight(base), base.font_style_italic.unwrap_or(false), &self.loaded_families, ); let ctx = base_span_ctx(base, font, &mut collector); let line_height = ctx.line_height; let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); for &cid in run { collect_node_spans( tree, cid, styles, ctx.clone(), &mut spans, &mut collector, &self.loaded_families, ); } self.push_shaped_item(base, line_height, spans, collector.clip_fills) } /// Shape generated text that owns a positioned pseudo box. /// /// Positioned `::before`/`::after` boxes do not participate in the taffy /// tree, but their text still uses the same authored webfonts, variable /// weight selection, transformations, and glyph rasterizer as an ordinary /// inline formatting context. The caller measures/finalizes/paints the /// returned item immediately against the pseudo's resolved content box. pub(crate) fn push_generated_text( &mut self, text: &str, style: &LayoutStyle, ) -> Option { let mut collector = Collector { last_was_space: true, clip_fills: Vec::new(), }; let font = resolve_loaded_font( style.font_family.as_deref(), crate::style::used_font_weight(style), style.font_style_italic.unwrap_or(false), &self.loaded_families, ); let context = base_span_ctx(style, font, &mut collector); let line_height = context.line_height; let attrs = SpanAttrs { font_size: context.font_size, line_height: context.line_height, letter_spacing: context.letter_spacing, letter_spacing_non_normal: context.letter_spacing_non_normal, weight: context.weight, optical_sizing: context.optical_sizing, font_id: context.font_id, variations: context.variations.clone(), italic: context.italic, underline: context.underline, color: context.color, family: context.family, clip_fill: context.clip_fill, }; let mut spans = Vec::new(); push_text( text, context.transform, context.white_space, &attrs, &mut spans, &mut collector, ); self.push_shaped_item(style, line_height, spans, collector.clip_fills) } /// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected /// spans into a cosmic-text buffer under `base`'s font metrics and /// alignment, and store it as a new inline item. fn push_shaped_item( &mut self, base: &LayoutStyle, line_h: f32, mut spans: Vec<(String, SpanAttrs)>, clip_fills: Vec, ) -> Option { let white_space = base.white_space.unwrap_or_default(); // Collapsible trailing whitespace does not widen the last line. if matches!( white_space, crate::WhiteSpace::Normal | crate::WhiteSpace::NoWrap | crate::WhiteSpace::PreLine ) { if let Some((text, _)) = spans.last_mut() { if text.ends_with(' ') { text.pop(); } } } if spans .iter() .all(|(text, _)| { text.is_empty() || (matches!( white_space, crate::WhiteSpace::Normal | crate::WhiteSpace::NoWrap | crate::WhiteSpace::PreLine ) && text.trim().is_empty() && !text.contains('\n')) }) { return None; } let base_size = base.font_size.unwrap_or(16.0); // Explicit line-height stays fractional. `normal` is derived from the // embedded face metrics with the same per-component pixel fitting as // Chromium's Linux font path. let mut forced_breaks = 0usize; let mut visible_after_last_break = false; for (text, _) in &spans { for ch in text.chars() { if ch == '\n' { forced_breaks += 1; visible_after_last_break = false; } else if !ch.is_whitespace() { visible_after_last_break = true; } } } let forced_lines = forced_breaks + usize::from(forced_breaks > 0 && visible_after_last_break); let forced_min_height = forced_lines as f32 * line_h.max(1.0); // cosmic-text asserts (an uncatchable process abort) if font size OR // line height is 0. `font-size:0` is a common whitespace-collapse trick // and drives both to 0, so floor both at 1px here. The glyphs stay // ~invisible, matching the intent, and one page can never abort a worker. let cosmic_size = base_size.max(1.0); let metrics = Metrics::new(cosmic_size, line_h.max(1.0)); let mut buffer = Buffer::new(&mut self.font_system, metrics); // Break only at word boundaries, never mid-word. This keeps the // min-content width (measured at width 0) equal to the longest word, // so a table/flex column never squeezes narrower than a whole word // and forces an ugly mid-word break; a single word wider than its // column overflows instead, matching normal `overflow-wrap: normal`. buffer.set_wrap( &mut self.font_system, if matches!( white_space, crate::WhiteSpace::NoWrap | crate::WhiteSpace::Pre ) { Wrap::None } else { Wrap::Word }, ); // Always Advanced shaping: Basic mis-maps per-span attribute // boundaries in the shaping backend (a multi-color run like body text // with links ends up coloring the wrong glyphs), and shaping is a // small fraction of a page's CSS-cascade-dominated render time. let mut variation_sets = Vec::>::new(); for (_, attrs) in &spans { if let Some(variations) = attrs.variations.as_ref() { if !variation_sets .iter() .any(|existing| **existing == **variations) { variation_sets.push(Arc::clone(variations)); } } } let rich = spans.iter().map(|(text, attrs)| { let variation_index = attrs .variations .as_ref() .map(|variations| { variation_sets .iter() .position(|existing| **existing == **variations) .expect("collected span variation set") + 1 }) .unwrap_or(0); (text.as_str(), attrs.to_attrs(variation_index)) }); let defaults = Attrs::new().family(Family::Name(FAMILY)); buffer.set_rich_text( &mut self.font_system, rich, &defaults, Shaping::Advanced, None, ); let align = match base.text_align { Some(taffy::AlignItems::CENTER) => Some(Align::Center), Some(taffy::AlignItems::FLEX_END) => Some(Align::End), _ => None, }; if let Some(a) = align { for line in buffer.lines.iter_mut() { line.set_align(Some(a)); } } let idx = self.items.len(); let text_indent = base.text_indent.unwrap_or(Dimension::Px(0.0)); let source_buffer = (!matches!(text_indent, Dimension::Px(value) if value == 0.0)) .then(|| buffer.clone()); self.items.push(InlineItem { buffer, source_buffer, text_indent, first_line_offset: 0.0, balance_wrap: base.text_wrap_style == Some(crate::TextWrapStyle::Balance), align, forced_min_height, origin: (0.0, 0.0), clip: None, clip_fills, variation_sets, }); Some(idx) } /// Measure the inline context `idx` at `width` (content-box width, or /// None for max-content), returning its shaped (width, height). Called by /// taffy's measure function during layout. pub fn measure(&mut self, idx: usize, width: Option) -> (f32, f32) { if idx & REPLACED_CONTEXT_BIT != 0 { let size = self.replaced[idx & !REPLACED_CONTEXT_BIT] .size(taffy::Size { width, height: None }); return (size.width, size.height); } let TextEngine { font_system, items, .. } = self; let item = &mut items[idx]; shape_with_text_indent(font_system, item, width); let (width, height) = buffer_size(&item.buffer, item.first_line_offset); (width, height.max(item.forced_min_height)) } /// Register a replaced element's intrinsic size as a taffy measure /// context. Percentage-sized image leaves still need their intrinsic /// max-content contribution while an auto-sized ancestor is measured. pub fn register_replaced( &mut self, width: f32, height: f32, style: &LayoutStyle, ) -> usize { let index = self.replaced.len(); self.replaced .push(ReplacedItem::from_style(width, height, style)); REPLACED_CONTEXT_BIT | index } /// Measure either a shaped text context or an intrinsic replaced element. /// Replaced boxes transfer a definite axis through their intrinsic ratio; /// with neither axis definite they contribute their natural size. pub fn measure_taffy( &mut self, idx: usize, known: taffy::Size>, available: taffy::Size, ) -> taffy::Size { if idx & REPLACED_CONTEXT_BIT != 0 { let replaced = self.replaced[idx & !REPLACED_CONTEXT_BIT]; let mut size = replaced.size(known); if replaced.zero_inline_min_content && known.width.is_none() && matches!(available.width, taffy::AvailableSpace::MinContent) { size.width = 0.0; } return size; } let width = known.width.or(match available.width { taffy::AvailableSpace::Definite(width) => Some(width), taffy::AvailableSpace::MinContent => Some(0.0), taffy::AvailableSpace::MaxContent => None, }); let (width, height) = self.measure(idx, width); taffy::Size { width, height } } /// After layout, pin each context to its final content-box origin and /// clip, reshaping once at the resolved width so paint draws the same /// line breaks the box was sized for. pub fn finalize( &mut self, idx: usize, content_origin: (f32, f32), content_width: f32, clip: Option, ) { let TextEngine { font_system, items, .. } = self; let item = &mut items[idx]; let content_width = content_width.max(0.0); shape_with_text_indent(font_system, item, Some(content_width)); let balanced_width = item .balance_wrap .then(|| balance_wrap_width(font_system, &mut item.buffer, content_width)) .flatten() .unwrap_or(content_width); let alignment_inset = (content_width - balanced_width).max(0.0) * match item.align { Some(Align::Center) => 0.5, Some(Align::End | Align::Right) => 1.0, _ => 0.0, }; item.origin = (content_origin.0 + alignment_inset, content_origin.1); item.clip = clip; } } /// A run of same-styled inline text. #[derive(Clone, PartialEq)] struct SpanAttrs { font_size: f32, line_height: f32, letter_spacing: f32, letter_spacing_non_normal: bool, weight: u16, optical_sizing: crate::FontOpticalSizing, font_id: Option, variations: Option>, italic: bool, underline: bool, color: [u8; 4], family: Arc, clip_fill: Option, } impl SpanAttrs { fn to_attrs(&self, variation_index: usize) -> Attrs<'_> { let mut a = Attrs::new().family(Family::Name(self.family.as_ref())); // Inline descendants keep their own computed font metrics inside the // enclosing line box. Without per-span metrics, an ``/`` // with a relative font-size was shaped at the block container's size // even though cascade had resolved the descendant correctly. a = a.metrics(Metrics::new( self.font_size.max(1.0), self.line_height.max(1.0), )); a = a.weight(Weight(self.weight)); a = a.font_weight_axis(self.weight as f32); if self.optical_sizing == crate::FontOpticalSizing::Auto { a = a.font_optical_size(self.font_size); } a = a.font_italic_axis(self.italic); if let Some(font_id) = self.font_id { a = a.font_id(font_id); } a = a.style(if self.italic { Style::Italic } else { Style::Normal }); if self.letter_spacing.is_finite() && self.letter_spacing != 0.0 { a = a.letter_spacing(self.letter_spacing / self.font_size.max(1.0)); } if self.letter_spacing_non_normal && self.letter_spacing.is_finite() && self.letter_spacing != 0.0 { let mut features = FontFeatures::new(); features .disable(FeatureTag::STANDARD_LIGATURES) .disable(FeatureTag::CONTEXTUAL_LIGATURES); a = a.font_features(features); } // Clip-text glyphs must be shaped with an opaque fill so their coverage // reaches paint; the real gradient is selected through metadata. let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color }; a = a.color(Color::rgba(color[0], color[1], color[2], color[3])); let fill = self .clip_fill .map_or(0, |index| (index + 1) << META_FILL_SHIFT); debug_assert_eq!(fill & !META_FILL_MASK, 0); let variation = variation_index << META_VARIATION_SHIFT; assert_eq!(variation & !META_VARIATION_MASK, 0); if let Some(variations) = self.variations.as_ref() { a = a.font_variations((**variations).clone()); } a = a.metadata(fill | variation | usize::from(self.underline)); a } } /// Inherited inline context threaded down the subtree while collecting spans. #[derive(Clone)] struct SpanCtx { font_size: f32, line_height: f32, letter_spacing: f32, letter_spacing_non_normal: bool, color: [u8; 4], weight: u16, optical_sizing: crate::FontOpticalSizing, font_id: Option, font_metrics: FaceMetrics, variations: Option>, italic: bool, underline: bool, transform: TextTransform, white_space: crate::WhiteSpace, family: Arc, clip_fill: Option, } struct Collector { last_was_space: bool, clip_fills: Vec, } /// DFS the inline subtree, appending whitespace-collapsed text runs. Adjacent /// runs with identical attributes are merged so cosmic-text sees the fewest /// spans. Collapsing spans HTML's insignificant whitespace (runs of spaces, /// tabs, and newlines fold to one space; leading space at the start of the /// context is dropped) exactly as `white-space: normal` requires. /// Root span context (and background-clip-text fill, when active) for an IFC /// whose base style is `base`. /// /// `-webkit-background-clip: text` on a transparent-colored element paints /// its background *through* the glyphs (gradient/solid text). When active, /// shape the glyphs in opaque white so their coverage renders, then recolor /// them from the background at paint time; otherwise transparent text stays /// transparent (and invisible), unchanged. fn resolved_font_variations( style: &LayoutStyle, _font: &ResolvedFont, ) -> Option> { let mut variations = FontVariations::new(); // Automatic high-level axes travel separately with every glyph so they // can be resolved against the face actually selected during fallback. // Only low-level authored settings need an allocated tuple here. for setting in style.font_variation_settings.as_deref().unwrap_or(&[]) { if setting.value.is_finite() { variations.set(VariationTag::new(&setting.tag), setting.value); } } (!variations.is_empty()).then(|| Arc::new(variations)) } fn base_span_ctx( base: &LayoutStyle, font: ResolvedFont, collector: &mut Collector, ) -> SpanCtx { let clip_fill = clip_text_fill(base).map(|fill| { let index = collector.clip_fills.len(); collector.clip_fills.push(fill); index }); let variations = resolved_font_variations(base, &font); let line_height = used_line_height_for_font(base, &font); SpanCtx { font_size: base.font_size.unwrap_or(16.0), line_height, letter_spacing: base.letter_spacing.unwrap_or(0.0), letter_spacing_non_normal: base.letter_spacing_non_normal.unwrap_or(false), color: base.color.unwrap_or([0, 0, 0, 255]), weight: crate::style::used_font_weight(base), optical_sizing: base.font_optical_sizing.unwrap_or_default(), font_id: font.font_id, font_metrics: font.metrics, variations, italic: base.font_style_italic.unwrap_or(false), underline: base.underline.unwrap_or(false), transform: base.text_transform.unwrap_or(TextTransform::None), white_space: base.white_space.unwrap_or_default(), family: font.family, clip_fill, } } fn collect_spans( tree: &DomTree, id: NodeId, styles: &std::collections::HashMap, ctx: SpanCtx, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector, loaded_families: &HashMap, ) { for cid in tree.children(id) { collect_node_spans( tree, cid, styles, ctx.clone(), out, c, loaded_families, ); } } /// Collect the spans contributed by one node (a text node's runs, or an /// element's whole subtree with its style threaded through). Split out of /// [`collect_spans`] so an inline *run* (a slice of siblings, not a whole /// container) can also be folded into one shaped buffer. fn collect_node_spans( tree: &DomTree, cid: NodeId, styles: &std::collections::HashMap, ctx: SpanCtx, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector, loaded_families: &HashMap, ) { let Some(node) = tree.get_node(cid) else { return }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => { let attrs = SpanAttrs { font_size: ctx.font_size, line_height: ctx.line_height, letter_spacing: ctx.letter_spacing, letter_spacing_non_normal: ctx.letter_spacing_non_normal, weight: ctx.weight, optical_sizing: ctx.optical_sizing, font_id: ctx.font_id, variations: ctx.variations.clone(), italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: Arc::clone(&ctx.family), clip_fill: ctx.clip_fill, }; push_text( contents, ctx.transform, ctx.white_space, &attrs, out, c, ); } _ => { let Some(elem) = node.as_element() else { return }; let style = styles.get(&cid); if style.map(|s| s.display == Display::None).unwrap_or(false) { return; } if elem.local.as_ref() == "br" { out.push(("\n".to_string(), SpanAttrs { font_size: ctx.font_size, line_height: ctx.line_height, letter_spacing: ctx.letter_spacing, letter_spacing_non_normal: ctx.letter_spacing_non_normal, weight: ctx.weight, optical_sizing: ctx.optical_sizing, font_id: ctx.font_id, variations: ctx.variations.clone(), italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: Arc::clone(&ctx.family), clip_fill: ctx.clip_fill, })); c.last_was_space = true; return; } let own_clip_fill = style.and_then(clip_text_fill).map(|fill| { let index = c.clip_fills.len(); c.clip_fills.push(fill); index }); let color = style.and_then(|s| s.color).unwrap_or(ctx.color); // A descendant with its own clip-text background replaces the // inherited fill. Transparent descendants otherwise continue an // ancestor's fill; an opaque text color paints normally. let clip_fill = own_clip_fill.or_else(|| { if color[3] == 0 { ctx.clip_fill } else { None } }); let requested_weight = style .map(crate::style::used_font_weight) .unwrap_or(ctx.weight); let font = style .and_then(|style| style.font_family.as_deref()) .map(|family| { resolve_loaded_font( Some(family), requested_weight, style .and_then(|style| style.font_style_italic) .unwrap_or(ctx.italic), loaded_families, ) }) .unwrap_or_else(|| ResolvedFont { family: Arc::clone(&ctx.family), font_id: ctx.font_id, metrics: ctx.font_metrics, }); let variations = style .map(|style| resolved_font_variations(style, &font)) .unwrap_or_else(|| ctx.variations.clone()); let child = SpanCtx { font_size: style .and_then(|style| style.font_size) .unwrap_or(ctx.font_size), line_height: style .map(|style| used_line_height_for_font(style, &font)) .unwrap_or(ctx.line_height), letter_spacing: style .and_then(|style| style.letter_spacing) .unwrap_or(ctx.letter_spacing), letter_spacing_non_normal: style .and_then(|style| style.letter_spacing_non_normal) .unwrap_or(ctx.letter_spacing_non_normal), color, weight: requested_weight, optical_sizing: style .and_then(|style| style.font_optical_sizing) .unwrap_or(ctx.optical_sizing), font_id: font.font_id, font_metrics: font.metrics, variations, italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false), // Underline propagates in: an ancestor's underline covers // descendant text; an element only sets its own via CSS. underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false), transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform), white_space: style .and_then(|s| s.white_space) .unwrap_or(ctx.white_space), family: font.family, clip_fill, }; collect_spans( tree, cid, styles, child, out, c, loaded_families, ); } } } fn push_text( raw: &str, transform: TextTransform, white_space: crate::WhiteSpace, attrs: &SpanAttrs, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector, ) { let mut buf = String::new(); let mut at_word_start = c.last_was_space; for ch in raw.chars() { if ch.is_whitespace() { match white_space { crate::WhiteSpace::Pre | crate::WhiteSpace::PreWrap | crate::WhiteSpace::BreakSpaces => buf.push(ch), crate::WhiteSpace::PreLine if ch == '\n' => { if buf.ends_with(' ') { buf.pop(); } buf.push('\n'); } _ if !c.last_was_space => buf.push(' '), _ => {} } c.last_was_space = true; at_word_start = true; } else { match transform { TextTransform::Uppercase => buf.extend(ch.to_uppercase()), TextTransform::Lowercase => buf.extend(ch.to_lowercase()), TextTransform::Capitalize if at_word_start => buf.extend(ch.to_uppercase()), _ => buf.push(ch), } c.last_was_space = false; at_word_start = false; } } if buf.is_empty() { return; } if let Some((last_text, last_attrs)) = out.last_mut() { if last_attrs == attrs { last_text.push_str(&buf); return; } } out.push((buf, attrs.clone())); } /// Total shaped size of a buffer: widest line, and the bottom of the last line. fn buffer_size(buffer: &Buffer, first_line_offset: f32) -> (f32, f32) { let mut w = 0.0f32; let mut h = 0.0f32; for (line_index, run) in buffer.layout_runs().enumerate() { let offset = if line_index == 0 { first_line_offset } else { 0.0 }; w = w.max((run.line_w + offset).max(0.0)); h = h.max(run.line_top + run.line_height); } (w.ceil(), h) } fn used_text_indent(value: Dimension, width: Option) -> f32 { let indent = match value { Dimension::Px(pixels) => pixels, Dimension::Percent(fraction) => width.unwrap_or(0.0) * fraction, _ => 0.0, }; if indent.is_finite() { indent } else { 0.0 } } /// Shape one IFC with a first-line-only indent. /// /// cosmic-text accepts one wrap width for every visual line. To retain the /// browser model, first probe the pristine paragraph at `width - indent`, take /// its first wrap boundary, split there, and reshape the tail at the full /// content width. The synthetic split lives only in the working clone, so /// repeated intrinsic/final Taffy measurements never accumulate hard breaks. fn shape_with_text_indent( font_system: &mut FontSystem, item: &mut InlineItem, width: Option, ) { let indent = used_text_indent(item.text_indent, width); item.first_line_offset = indent * match item.align { Some(Align::Center) => 0.5, Some(Align::End | Align::Right) => 0.0, _ => 1.0, }; let Some(source) = item.source_buffer.as_ref() else { item.buffer .set_size(font_system, width.map(|value| value.max(0.0)), None); item.buffer.shape_until_scroll(font_system, false); return; }; item.buffer = source.clone(); let mut first_break = None; if let Some(full_width) = width { item.buffer .set_size(font_system, Some((full_width - indent).max(0.0)), None); item.buffer.shape_until_scroll(font_system, false); first_break = item .buffer .layout_runs() .next() .and_then(|run| run.glyphs.iter().map(|glyph| glyph.end).max()); } item.buffer = source.clone(); if let (Some(mut split), Some(first_line)) = (first_break, item.buffer.lines.first()) { let text = first_line.text(); while split < text.len() { let Some(ch) = text[split..].chars().next() else { break }; if !ch.is_whitespace() { break; } split += ch.len_utf8(); } if split > 0 && split < text.len() { let tail = item.buffer.lines[0].split_off(split); item.buffer.lines.insert(1, tail); } } item.buffer .set_size(font_system, width.map(|value| value.max(0.0)), None); item.buffer.shape_until_scroll(font_system, false); } /// Chromium's bisection implementation only balances paragraphs of at most /// six lines. Keeping the same cap bounds repeated shaping on long body copy /// and confines this work to the heading-sized content the property targets. const MAX_BALANCED_LINES: usize = 6; /// Tighten `buffer` to the narrowest (within one CSS pixel) wrapping width /// that retains its natural line count. This is deliberately a final-shaping /// operation: intrinsic measurement and the block's used geometry remain /// unchanged, while the line grouping changes. fn balance_wrap_width( font_system: &mut FontSystem, buffer: &mut Buffer, available_width: f32, ) -> Option { let natural = buffer.layout_runs().collect::>(); let line_count = natural.len(); if !(2..=MAX_BALANCED_LINES).contains(&line_count) { return None; } // Blink's bisection path is inapplicable to forced breaks; balancing each // hard-break-delimited paragraph needs separate segment constraints. if buffer.lines.len() > 1 { return None; } let average_line_width = natural.iter().map(|run| run.line_w).sum::() / line_count as f32; drop(natural); let mut lower = (average_line_width * 0.8).clamp(0.0, available_width); let mut upper = available_width; while lower + 1.0 < upper { let middle = (lower + upper) * 0.5; buffer.set_size(font_system, Some(middle), None); buffer.shape_until_scroll(font_system, false); if buffer.layout_runs().count() == line_count { upper = middle; } else { lower = middle; } } buffer.set_size(font_system, Some(upper), None); buffer.shape_until_scroll(font_system, false); Some(upper) } /// Does `id` establish an inline formatting context made purely of text and /// plain inline formatting (no atomic inline boxes, no block children, no /// inline element carrying its own box)? Such a container collapses cleanly /// to one shaped buffer; anything else keeps the general build path. fn is_pure_text_ifc( tree: &DomTree, id: NodeId, styles: &std::collections::HashMap, ) -> bool { let Some(style) = styles.get(&id) else { return false; }; // Out-of-flow generated boxes neither contribute to nor interrupt the // host's inline formatting context. Keeping a positioned decorative // pseudo must not demote an otherwise text-only shrink-to-fit control to // approximate per-word flex leaves: that path can disagree with shaped // space advances and wrap text at its own max-content width. let has_in_flow_pseudo = |pseudo: Option<&LayoutStyle>| { pseudo.is_some_and(|pseudo| { pseudo.display != Display::None && !matches!(pseudo.position, Some(taffy::Position::Absolute)) }) }; if has_in_flow_pseudo(style.before_pseudo.as_deref()) || has_in_flow_pseudo(style.after_pseudo.as_deref()) { return false; } // Only containers that lay their children out in normal flow (block, or // the flex-column stand-ins our UA sheet uses for td/th/center). A real // flex/grid row with inline children is rare and better left to taffy. let flow = style.display == Display::Block || style.is_inline_block || (style.display == Display::Flex && style.flex_direction == Some(taffy::FlexDirection::Column)); if !flow { return false; } let mut has_text = false; let children = tree.children(id); if children.is_empty() { return false; } for cid in &children { if !inline_child_ok(tree, *cid, styles, &mut has_text) { return false; } } has_text } /// Replaced / atomic-inline tags: their box does not contain text, so folding /// one into a shaped buffer would drop its content entirely. A subtree that /// contains one is never a pure-text IFC (it keeps the general build path, /// where the element gets a real taffy box). This is by tag, not display, so a /// stylesheet setting `img{display:inline}` cannot trick us into folding it. fn is_replaced(local: &str) -> bool { matches!( local, "img" | "svg" | "canvas" | "video" | "audio" | "iframe" | "embed" | "object" | "input" | "textarea" | "select" | "button" | "progress" | "meter" ) } /// Is `cid` (and its whole subtree) inline-level, in-flow content safe to fold /// into a shaped buffer? Sets `has_text` if it contributes any non-whitespace /// text. Inline wrappers (``, ``, ``, ``, ``, ...) are /// accepted and recursed into even when they carry a background or border of /// their own: keeping the whole paragraph as one shaped run (correct wrapping /// plus per-span color/weight/style/underline that `collect_spans` threads) is /// worth losing an inline decoration, and it avoids the taffy-discouraged /// flex word-promotion path that breaks real prose wrapping. Only boxes that /// genuinely cannot fold are rejected: replaced/atomic elements, block-level /// children, floats, out-of-flow positioned boxes, and elements with generated /// content (which would be lost). fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashMap, has_text: &mut bool) -> bool { let Some(node) = tree.get_node(cid) else { return true }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => { if !contents.trim().is_empty() { *has_text = true; } true } obscura_dom::tree::NodeData::Element { .. } => { let Some(elem) = node.as_element() else { return true }; let Some(style) = styles.get(&cid) else { return false }; if style.display == Display::None { return true; // removed from flow; ignore its subtree } if elem.local.as_ref() == "br" { *has_text = true; return true; } // A replaced element or an atomic inline-block has its own box with // non-text content; it must stay a real taffy box (keep flex path). if is_replaced(elem.local.as_ref()) || style.is_inline_block { return false; } // Only genuinely inline-level, in-flow boxes fold. A block-level // child, a float, or an out-of-flow positioned box each needs the // general path; so does an element with generated ::before/::after // content (lost if folded) or an overflow clip of its own. let foldable_inline = style.display == Display::Inline && style.position.is_none() && style.float.is_none() && !style.overflow_hidden && style.before_pseudo.is_none() && style.after_pseudo.is_none(); if !foldable_inline { return false; } for gc in tree.children(cid) { if !inline_child_ok(tree, gc, styles, has_text) { return false; } } true } _ => true, } } /// Content-box origin for a container whose border box is `rect`, i.e. inside /// its border and padding: where inline text actually starts. pub fn content_origin(rect: &Rect, style: &LayoutStyle) -> (f32, f32) { (rect.x + style.border.left + style.padding.left, rect.y + style.border.top + style.padding.top) } pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 { (rect.width - style.border.left - style.border.right - style.padding.left - style.padding.right).max(0.0) } /// The background to paint through the glyphs for `-webkit-background-clip: text` /// when the element's own text color is transparent (the common gradient-text /// technique on hero headings and buttons). Returns the background gradient as /// is, or a solid background color as a flat two-stop gradient. `None` when the /// element is not a transparent-text clip-to-text box, so ordinary transparent /// text still renders invisibly. fn clip_text_fill(style: &LayoutStyle) -> Option<(f32, Vec<([u8; 4], Option)>)> { if !style.background_clip_text { return None; } // Only when the text itself is transparent: an opaque color paints normally // and the clip is a no-op we would otherwise recolor incorrectly. if style.color.map(|c| c[3] != 0).unwrap_or(true) { return None; } if let Some(g) = &style.background_gradient { if g.1.len() >= 2 { return Some(g.clone()); } } let bg = style.background_color.filter(|c| c[3] != 0)?; Some((180.0, vec![(bg, Some(0.0)), (bg, Some(1.0))])) } /// Sample a CSS linear gradient at point `(x, y)` inside a `w` x `h` text box, /// returning an rgba color. `angle` is CSS degrees clockwise from 12 o'clock /// (0 = to top, 90 = to right, 180 = to bottom), matching `parse_linear_gradient` /// and `paint::paint_linear_gradient`. Positionless stops are spread evenly. fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option)>), x: f32, y: f32, w: f32, h: f32) -> [u8; 4] { let (angle, stops) = fill; match stops.len() { 0 => return [0, 0, 0, 255], 1 => return stops[0].0, _ => {} } let rad = angle.to_radians(); let (dx, dy) = (rad.sin(), -rad.cos()); let (w, h) = (w.max(1.0), h.max(1.0)); // Full extent of the box along the gradient direction (the CSS gradient-line // length), so the endpoints land at the box's projected corners. let len = (w * dx).abs() + (h * dy).abs(); let t = if len <= 0.0 { 0.5 } else { (((x - w / 2.0) * dx + (y - h / 2.0) * dy) / len + 0.5).clamp(0.0, 1.0) }; let n = stops.len(); let pos = |i: usize| stops[i].1.unwrap_or(i as f32 / (n as f32 - 1.0)).clamp(0.0, 1.0); // Walk to the pair of stops surrounding t, then interpolate between them. let mut lo = 0usize; while lo + 1 < n && pos(lo + 1) < t { lo += 1; } let hi = (lo + 1).min(n - 1); let (p0, p1) = (pos(lo), pos(hi)); let f = if (p1 - p0).abs() < 1e-6 { 0.0 } else { ((t - p0) / (p1 - p0)).clamp(0.0, 1.0) }; let (c0, c1) = (stops[lo].0, stops[hi].0); let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8; [lerp(c0[0], c1[0]), lerp(c0[1], c1[1]), lerp(c0[2], c1[2]), lerp(c0[3], c1[3])] } impl TextEngine { /// Rasterize inline context `idx` into `pixmap`, honoring its finalized /// clip. Tests and generated items whose coordinates do not change between /// layout and paint use this path. pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32)) { self.paint_item_with_clip(idx, pixmap, offset, None); } /// Rasterize an inline context with a capture-space clip override. Root /// scrolling is layered over immutable document layout, so the glyph /// origin and any overflow clip must both be sampled in the current /// viewport rather than mixing viewport and document coordinates. pub fn paint_item_with_clip( &mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32), clip_override: Option, ) { let TextEngine { font_system, swash, variable_swash, items, .. } = self; let Some(item) = items.get_mut(idx) else { return }; let (ox, oy) = (item.origin.0 + offset.0, item.origin.1 + offset.1); // The glyph origin shifts by the container's accumulated translate, // but the clip is already in screen space (owner-shifted at // `resolve_clip_rects`) and must not move with the container, or a // translated slide would drag its viewport's clip along with it. let clip = clip_override.or(item.clip); let pw = pixmap.width() as i32; let ph = pixmap.height() as i32; let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height)); // Collect underline segments before drawing glyphs (both borrow the // buffer). Underline is carried per glyph via metadata; group runs of // consecutive underlined glyphs on a line into one stroke below the // baseline. Done first so the draw() mutable borrow does not overlap. let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color let mut fill_bounds: Vec> = vec![None; item.clip_fills.len()]; for (line_index, run) in item.buffer.layout_runs().enumerate() { let line_offset = if line_index == 0 { item.first_line_offset } else { 0.0 }; let base_y = run.line_y; let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color for g in run.glyphs { let underlined = g.metadata & META_UNDERLINE != 0; if let Some(fill_index) = metadata_fill(g.metadata) { if let Some(bounds) = fill_bounds.get_mut(fill_index) { let glyph_bounds = ( g.x + line_offset, run.line_top, g.x + line_offset + g.w, run.line_top + run.line_height, ); *bounds = Some(match *bounds { Some((x0, y0, x1, y1)) => ( x0.min(glyph_bounds.0), y0.min(glyph_bounds.1), x1.max(glyph_bounds.2), y1.max(glyph_bounds.3), ), None => glyph_bounds, }); } } let col = g.color_opt.map(|c| [c.r(), c.g(), c.b(), c.a()]).unwrap_or([0, 0, 0, 255]); if underlined { match &mut seg { Some((_, x1, fs, c)) if *c == col => { *x1 = g.x + line_offset + g.w; *fs = fs.max(g.font_size); } _ => { if let Some((x0, x1, fs, c)) = seg.take() { underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c)); } seg = Some(( g.x + line_offset, g.x + line_offset + g.w, g.font_size, col, )); } } } else if let Some((x0, x1, fs, c)) = seg.take() { underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c)); } } if let Some((x0, x1, fs, c)) = seg.take() { underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c)); } } // Fallback color if a glyph carries none (shouldn't happen: every span // sets one), black. let default = Color::rgba(0, 0, 0, 255); // `-webkit-background-clip: text`: rasterize each glyph while its // metadata is still available, then sample that span's background // gradient across the span's own shaped bounds. Buffer::draw omits // metadata from its callback, so using it here would force one fill // over the entire heading and lose inline accent gradients. let clip_fills = item.clip_fills.clone(); let pixels = pixmap.pixels_mut(); for (line_index, run) in item.buffer.layout_runs().enumerate() { let line_offset = if line_index == 0 { item.first_line_offset } else { 0.0 }; for glyph in run.glyphs { let physical = glyph.physical((line_offset, 0.0), 1.0); let glyph_color = glyph.color_opt.unwrap_or(default); let fill_index = metadata_fill(glyph.metadata); let mut draw_pixel = |x, y, color: Color| { // cosmic-text's mask rasterizer replaces the authored // alpha with glyph coverage (see its `blend base alpha?` // TODO). Reapply the span alpha here so transparent and // translucent CSS text do not become opaque at paint. let a = color.a() as u32 * glyph_color.a() as u32 / 255; if a == 0 { return; } let gx = physical.x + x; let gy = run.line_y as i32 + physical.y + y; let (r, g, b) = fill_index .and_then(|index| { let fill = clip_fills.get(index)?; let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?; let sampled = sample_gradient( fill, gx as f32 + 0.5 - x0, gy as f32 + 0.5 - y0, x1 - x0, y1 - y0, ); Some((sampled[0], sampled[1], sampled[2])) }) .unwrap_or_else(|| (color.r(), color.g(), color.b())); let px = ox as i32 + gx; let py = oy as i32 + gy; if let Some((cx0, cy0, cx1, cy1)) = clip_bounds { if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 { return; } } if px < 0 || px >= pw || py < 0 || py >= ph { return; } let idx = (py * pw + px) as usize; let dst = pixels[idx]; let sa = a; let sr = (r as u32 * sa) / 255; let sg = (g as u32 * sa) / 255; let sb = (b as u32 * sa) / 255; let inv = 255 - sa; let out_a = sa + (dst.alpha() as u32 * inv / 255); if out_a == 0 { return; } let out_r = sr + (dst.red() as u32 * inv / 255); let out_g = sg + (dst.green() as u32 * inv / 255); let out_b = sb + (dst.blue() as u32 * inv / 255); pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8) .unwrap_or(dst); }; let explicit_variations = metadata_variation(glyph.metadata) .and_then(|index| item.variation_sets.get(index)) .map(Arc::clone); let effective_variations = glyph.font_is_variable.then(|| { variable_swash.effective_variations( font_system, physical.cache_key.font_id, glyph.font_weight_axis_opt, glyph.font_optical_size_opt, glyph.font_italic_axis, explicit_variations, ) }).flatten(); if let Some(variations) = effective_variations { variable_swash.with_pixels( font_system, physical.cache_key, variations, glyph_color, &mut draw_pixel, ); } else { swash.with_pixels( font_system, physical.cache_key, glyph_color, &mut draw_pixel, ); } } } // Stroke underline segments with the same authored alpha as their // glyphs. Transparent text decorations are transparent too. for (x0, x1, y, thick, col) in underlines { if col[3] == 0 { continue; } let t = thick.max(1.0).round() as i32; for dt in 0..t { let py = oy as i32 + y as i32 + dt; if py < 0 || py >= ph { continue; } if let Some((_, cy0, _, cy1)) = clip_bounds { if (py as f32) < cy0 || (py as f32) >= cy1 { continue; } } for px in (ox + x0) as i32..(ox + x1) as i32 { if px < 0 || px >= pw { continue; } if let Some((cx0, _, cx1, _)) = clip_bounds { if (px as f32) < cx0 || (px as f32) >= cx1 { continue; } } let i = (py * pw + px) as usize; let dst = pixels[i]; let sa = col[3] as u32; let inv = 255 - sa; let out_a = sa + dst.alpha() as u32 * inv / 255; let out_r = col[0] as u32 * sa / 255 + dst.red() as u32 * inv / 255; let out_g = col[1] as u32 * sa / 255 + dst.green() as u32 * inv / 255; let out_b = col[2] as u32 * sa / 255 + dst.blue() as u32 * inv / 255; pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba( out_r as u8, out_g as u8, out_b as u8, out_a as u8, ) .unwrap_or(dst); } } } } } #[cfg(test)] mod tests { use super::*; use base64::Engine as _; const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; #[test] fn normal_line_height_grid_fits_each_face_metric() { // Values measured from Chromium 145 using the same bundled Linux // platform faces. Small fractional sizes expose the difference from // rounding a single 1.15 multiplier. assert_eq!( normal_line_height(9.3333, bundled_face_metrics(FAMILY)), 10.0 ); assert_eq!( normal_line_height(12.0, bundled_face_metrics(FAMILY)), 14.0 ); assert_eq!( normal_line_height(13.0, bundled_face_metrics(SERIF_FAMILY)), 16.0 ); assert_eq!( normal_line_height(13.0, bundled_face_metrics(MONO_FAMILY)), 15.0 ); // Poppins's 1000-unit hhea metrics are 1050/-350 with a 100-unit // gap. A 64px normal line is therefore 67 + 22 + 6 = 95px, rather // than the 74px produced by the old generic-sans constants. assert_eq!( normal_line_height( 64.0, FaceMetrics { ascent: 1050.0, descent: 350.0, line_gap: 100.0, units_per_em: 1000.0, }, ), 95.0 ); } #[test] fn declared_web_family_keeps_the_loaded_faces_line_metrics() { let engine = TextEngine::new_with_web_fonts(&[WebFont { data: FALLBACK.to_vec(), family: Some("Page Face".to_string()), weight: Some((400, 400)), italic: Some(false), }]); let resolved = resolve_loaded_font(Some("Page Face"), 400, false, &engine.loaded_families); let expected = font_metrics(&engine.font_system.db(), resolved.font_id.unwrap()).unwrap(); assert_eq!(resolved.metrics, expected); let style = LayoutStyle { font_family: Some("Page Face".to_string()), font_size: Some(64.0), line_height: Some(crate::LineHeight::Normal), ..Default::default() }; assert_eq!( used_line_height_for_font(&style, &resolved), normal_line_height(64.0, expected) ); } #[test] fn replaced_percentage_math_only_zeroes_inline_min_content() { for expression_index in [0, 4] { let mut engine = TextEngine::new(); let mut style = LayoutStyle::default(); style.size_expressions[expression_index] = Some("calc(100% - 1px)".to_string()); let item = engine.register_replaced(800.0, 400.0, &style); let unknown = taffy::Size { width: None, height: None, }; let min_content = engine.measure_taffy( item, unknown, taffy::Size { width: taffy::AvailableSpace::MinContent, height: taffy::AvailableSpace::MaxContent, }, ); let max_content = engine.measure_taffy( item, unknown, taffy::Size { width: taffy::AvailableSpace::MaxContent, height: taffy::AvailableSpace::MaxContent, }, ); let final_size = engine.measure_taffy( item, taffy::Size { width: Some(300.0), height: None, }, taffy::Size { width: taffy::AvailableSpace::Definite(300.0), height: taffy::AvailableSpace::MaxContent, }, ); assert_eq!(min_content.width, 0.0); assert_eq!(max_content.width, 800.0); assert_eq!(max_content.height, 400.0); assert_eq!(final_size.width, 300.0); assert_eq!(final_size.height, 150.0); } } #[test] fn both_auto_replaced_constraints_transfer_through_preferred_ratio() { let unknown = taffy::Size { width: None, height: None, }; let measure = |style: &LayoutStyle| { ReplacedItem::from_style(512.0, 323.0, style).size(unknown) }; let max_height = LayoutStyle { max_height: Dimension::Px(128.0), ..Default::default() }; let size = measure(&max_height); assert!((size.width - 202.89783).abs() < 0.001, "{size:?}"); assert!((size.height - 128.0).abs() < 0.001, "{size:?}"); let max_width = LayoutStyle { max_width: Dimension::Px(256.0), ..Default::default() }; let size = measure(&max_width); assert!((size.width - 256.0).abs() < 0.001, "{size:?}"); assert!((size.height - 161.5).abs() < 0.001, "{size:?}"); let min_width = LayoutStyle { min_width: Dimension::Px(1024.0), ..Default::default() }; let size = measure(&min_width); assert!((size.width - 1024.0).abs() < 0.001, "{size:?}"); assert!((size.height - 646.0).abs() < 0.001, "{size:?}"); let min_height = LayoutStyle { min_height: Dimension::Px(646.0), ..Default::default() }; let size = measure(&min_height); assert!((size.width - 1024.0).abs() < 0.001, "{size:?}"); assert!((size.height - 646.0).abs() < 0.001, "{size:?}"); // A non-intrinsic authored ratio is the preferred ratio used for // transfer, rather than the decoded resource's natural ratio. let authored_ratio = LayoutStyle { max_height: Dimension::Px(128.0), aspect_ratio: Some(4.0), ..Default::default() }; let size = measure(&authored_ratio); assert!((size.width - 512.0).abs() < 0.001, "{size:?}"); assert!((size.height - 128.0).abs() < 0.001, "{size:?}"); } #[test] fn missing_font_weights_follow_css_search_order() { assert_eq!(match_font_weight(500, &[400, 700]), 400); assert_eq!(match_font_weight(600, &[400, 500, 700]), 700); assert_eq!(match_font_weight(300, &[100, 400, 700]), 100); assert_eq!(match_font_weight(800, &[400, 700]), 700); assert_eq!(match_font_weight(500, &[400, 500, 700]), 500); } #[test] fn declared_family_selects_a_face_with_a_different_internal_name() { let family = LoadedFamily { faces: vec![ LoadedFace { name: Arc::from("Poppins"), font_id: None, metrics: bundled_face_metrics(FAMILY), min_weight: 400, max_weight: 400, italic: false, }, LoadedFace { name: Arc::from("Poppins Medium"), font_id: None, metrics: bundled_face_metrics(FAMILY), min_weight: 500, max_weight: 500, italic: false, }, LoadedFace { name: Arc::from("Poppins"), font_id: None, metrics: bundled_face_metrics(FAMILY), min_weight: 700, max_weight: 700, italic: false, }, ], }; let loaded = HashMap::from([("poppins".to_string(), family)]); let medium = resolve_loaded_font(Some("Poppins, sans-serif"), 500, false, &loaded); assert_eq!(medium.family.as_ref(), "Poppins Medium"); let semibold = resolve_loaded_font(Some("Poppins"), 600, false, &loaded); assert_eq!(semibold.family.as_ref(), "Poppins"); } #[test] fn ranged_face_shapes_at_its_font_database_weight() { let tree = obscura_dom::parse_html("

Variable family

"); let copy = tree.get_element_by_id("copy").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Block; style.font_family = Some("test variable".to_string()); style.font_weight = Some("700".to_string()); style.font_size = Some(32.0); let styles = HashMap::from([(copy, style)]); let mut engine = TextEngine::new(); let regular_id = engine .font_system .db() .faces() .find(|face| { face.weight.0 == 400 && matches!(face.style, cosmic_text::fontdb::Style::Normal) && face.families.iter().any(|(name, _)| name == FAMILY) }) .map(|face| face.id) .unwrap(); engine.loaded_families.insert( "test variable".to_string(), LoadedFamily { faces: vec![LoadedFace { name: Arc::from(FAMILY), font_id: Some(regular_id), metrics: bundled_face_metrics(FAMILY), min_weight: 100, max_weight: 900, italic: false, }], }, ); let item = engine.try_build(&tree, copy, &styles).unwrap(); engine.measure(item, None); let font_id = engine.items[item] .buffer .layout_runs() .next() .and_then(|run| run.glyphs.first()) .map(|glyph| glyph.font_id) .unwrap(); let face = engine.font_system.db().face(font_id).unwrap(); assert!( face.families.iter().any(|(name, _)| name == FAMILY), "authored family must not fall through to an unrelated exact-weight face" ); assert_eq!(face.weight.0, 400); } #[test] fn descriptor_selected_resource_pins_the_exact_font_face() { let data = SANS_R.to_vec(); let mut engine = TextEngine::new_with_web_fonts(&[ WebFont { data: data.clone(), family: Some("Pinned Variable".to_string()), weight: Some((100, 400)), italic: Some(false), }, WebFont { data, family: Some("Pinned Variable".to_string()), weight: Some((500, 900)), italic: Some(false), }, ]); let loaded = &engine.loaded_families["pinned variable"]; assert_eq!(loaded.faces.len(), 2); let first_id = loaded.faces[0].font_id.unwrap(); let selected = resolve_loaded_font( Some("Pinned Variable"), 700, false, &engine.loaded_families, ); let selected_id = selected.font_id.unwrap(); assert_ne!( first_id, selected_id, "the fixture must contain two distinct database resources" ); let selected_font = engine .font_system .get_font(selected_id) .expect("the descriptor-selected database face must be loadable"); assert_eq!(selected_font.id(), selected_id); let tree = obscura_dom::parse_html( "

Build whatever you want, without touching your CSS file.

", ); let copy = tree.get_element_by_id("copy").unwrap(); let style = LayoutStyle { display: Display::Block, font_family: Some("Pinned Variable".to_string()), font_weight: Some("700".to_string()), font_size: Some(32.0), ..Default::default() }; let item = engine .try_build(&tree, copy, &HashMap::from([(copy, style)])) .unwrap(); assert_eq!( engine.items[item].buffer.lines[0] .attrs_list() .get_span(0) .font_id_opt, Some(selected_id), "the resolved face must survive rich-text span construction" ); engine.measure(item, None); let shaped_id = engine.items[item] .buffer .layout_runs() .next() .and_then(|run| run.glyphs.first()) .map(|glyph| glyph.font_id) .unwrap(); assert_eq!( shaped_id, selected_id, "family/default-weight rematching must not replace the descriptor-selected resource" ); } #[test] fn ranged_face_keeps_requested_weight_as_fallback_axis_intent() { let loaded = HashMap::from([( "inter".to_string(), LoadedFamily { faces: vec![LoadedFace { name: Arc::from("Inter"), font_id: None, metrics: bundled_face_metrics(FAMILY), min_weight: 100, max_weight: 900, italic: false, }], }, )]); let resolved = resolve_loaded_font(Some("Inter, sans-serif"), 725, false, &loaded); assert_eq!(resolved.family.as_ref(), "Inter"); let style = LayoutStyle { font_weight: Some("725".to_string()), ..Default::default() }; let mut collector = Collector { last_was_space: true, clip_fills: Vec::new(), }; let context = base_span_ctx(&style, resolved, &mut collector); assert_eq!(context.weight, 725); } #[test] fn glyph_metadata_keeps_fill_and_variations_independent() { let mut variations = FontVariations::new(); variations.set(VariationTag::new(b"wght"), 725.0); let attrs = SpanAttrs { font_size: 16.0, line_height: 18.0, letter_spacing: 0.0, letter_spacing_non_normal: false, weight: 400, optical_sizing: crate::FontOpticalSizing::Auto, font_id: None, variations: Some(Arc::new(variations)), italic: false, underline: true, color: [1, 2, 3, 255], family: Arc::from(FAMILY), clip_fill: Some(37), }; let shaped = attrs.to_attrs(42); assert_ne!(shaped.metadata & META_UNDERLINE, 0); assert_eq!(metadata_fill(shaped.metadata), Some(37)); assert_eq!(metadata_variation(shaped.metadata), Some(41)); assert_eq!( shaped .font_variations .iter() .find(|variation| variation.tag == VariationTag::new(b"wght")) .map(|variation| variation.value.0), Some(725.0) ); } #[test] fn static_text_keeps_the_zero_allocation_variation_path() { let tree = obscura_dom::parse_html("

ordinary text

"); let copy = tree.get_element_by_id("copy").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Block; let static_font = ResolvedFont { family: Arc::from(FAMILY), font_id: None, metrics: bundled_face_metrics(FAMILY), }; assert!(resolved_font_variations(&style, &static_font).is_none()); let styles = HashMap::from([(copy, style)]); let mut engine = TextEngine::new(); let item = engine.try_build(&tree, copy, &styles).unwrap(); assert!(engine.items[item].variation_sets.is_empty()); assert_eq!(engine.items[item].variation_sets.capacity(), 0); } #[test] fn programmatic_nonfinite_variations_never_reach_shape_or_raster() { let style = LayoutStyle { font_variation_settings: Some(vec![ crate::FontVariationSetting { tag: *b"opsz", value: f32::NAN, }, crate::FontVariationSetting { tag: *b"wght", value: f32::INFINITY, }, ]), ..Default::default() }; let font = ResolvedFont { family: Arc::from(FAMILY), font_id: None, metrics: bundled_face_metrics(FAMILY), }; assert!(resolved_font_variations(&style, &font).is_none()); } #[test] fn non_normal_letter_spacing_reaches_shaping_features() { let span = SpanAttrs { font_size: 20.0, line_height: 24.0, letter_spacing: 2.0, letter_spacing_non_normal: true, weight: 400, optical_sizing: crate::FontOpticalSizing::Auto, font_id: None, variations: None, italic: false, underline: false, color: [0, 0, 0, 255], family: Arc::from(FAMILY), clip_fill: None, }; let attrs = span.to_attrs(1); assert_eq!(attrs.letter_spacing_opt.unwrap().0, 0.1); assert!(attrs.font_features.features.iter().any(|feature| { feature.tag == FeatureTag::STANDARD_LIGATURES && feature.value == 0 })); assert!(attrs.font_features.features.iter().any(|feature| { feature.tag == FeatureTag::CONTEXTUAL_LIGATURES && feature.value == 0 })); let mut zero = span; zero.letter_spacing = 0.0; assert!(zero.to_attrs(1).font_features.features.is_empty()); } fn variable_font_fixture() -> Vec { let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64") .chars() .filter(|ch| !ch.is_whitespace()) .collect(); let compressed = base64::engine::general_purpose::STANDARD .decode(encoded) .expect("decode variable-font fixture"); wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture") } fn isolated_fallback_engine() -> ( TextEngine, cosmic_text::fontdb::ID, cosmic_text::fontdb::ID, ) { let mut engine = TextEngine::new_with_web_fonts(&[ WebFont { data: include_bytes!("../../../vendor/cosmic-text/fonts/NotoSansArabic.ttf") .to_vec(), family: Some("Static Primary".to_string()), weight: Some((400, 400)), italic: Some(false), }, WebFont { data: variable_font_fixture(), family: Some("Variable Fallback".to_string()), weight: Some((100, 900)), italic: Some(false), }, ]); let static_id = engine.loaded_families["static primary"].faces[0] .font_id .unwrap(); let variable_id = engine.loaded_families["variable fallback"].faces[0] .font_id .unwrap(); let remove = engine .font_system .db() .faces() .map(|face| face.id) .filter(|id| *id != static_id && *id != variable_id) .collect::>(); let db = engine.font_system.db_mut(); for id in remove { db.remove_face(id); } (engine, static_id, variable_id) } fn render_variable_fallback( optical_sizing: crate::FontOpticalSizing, ) -> ( cosmic_text::fontdb::ID, std::collections::HashMap<[u8; 4], std::collections::HashSet>, ) { let (mut engine, _, variable_id) = isolated_fallback_engine(); let tree = obscura_dom::parse_html("

A

"); let copy = tree.get_element_by_id("copy").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Block; style.font_family = Some("Static Primary".to_string()); style.font_weight = Some("900".to_string()); style.font_size = Some(40.0); style.font_optical_sizing = Some(optical_sizing); let item = engine .try_build(&tree, copy, &HashMap::from([(copy, style)])) .unwrap(); engine.measure(item, Some(100.0)); engine.finalize(item, (0.0, 0.0), 100.0, None); let shaped_id = engine.items[item] .buffer .layout_runs() .next() .and_then(|run| run.glyphs.first()) .map(|glyph| glyph.font_id) .unwrap(); assert_eq!(shaped_id, variable_id); let mut pixmap = tiny_skia::Pixmap::new(100, 80).unwrap(); engine.paint_item(item, &mut pixmap, (0.0, 0.0)); let mut axes = std::collections::HashMap::< [u8; 4], std::collections::HashSet, >::new(); for key in engine.variable_swash.images.keys() { for variation in key.variations.iter() { axes.entry(*variation.tag.as_bytes()) .or_default() .insert(variation.value.0.to_bits()); } } (shaped_id, axes) } #[test] fn automatic_axes_follow_the_actual_variable_fallback_face() { let (_, auto_axes) = render_variable_fallback(crate::FontOpticalSizing::Auto); let (_, none_axes) = render_variable_fallback(crate::FontOpticalSizing::None); assert_eq!( auto_axes.get(b"wght"), Some(&std::collections::HashSet::from([900.0f32.to_bits()])) ); assert_eq!( none_axes.get(b"wght"), Some(&std::collections::HashSet::from([900.0f32.to_bits()])) ); assert_eq!( auto_axes.get(b"opsz"), // The fixture's opsz range ends at 32; CSS's automatic 40px // coordinate must be clamped against the actual fallback face. Some(&std::collections::HashSet::from([32.0f32.to_bits()])) ); assert!(!none_axes.contains_key(b"opsz")); } #[test] fn unsupported_and_clamped_axes_share_effective_raster_identity() { let (mut engine, _, variable_id) = isolated_fallback_engine(); let first = { let mut settings = FontVariations::new(); settings .set(VariationTag::new(b"NOPE"), 1.0) .set(VariationTag::new(b"wght"), 5_000.0); Arc::new(settings) }; let second = { let mut settings = FontVariations::new(); settings .set(VariationTag::new(b"NOPE"), 999.0) .set(VariationTag::new(b"wght"), 900.0); Arc::new(settings) }; let first = engine .variable_swash .effective_variations( &mut engine.font_system, variable_id, Some(400.0), Some(40.0), false, Some(first), ) .unwrap(); let second = engine .variable_swash .effective_variations( &mut engine.font_system, variable_id, Some(400.0), Some(40.0), false, Some(second), ) .unwrap(); assert_eq!(first, second); assert!(first .iter() .all(|variation| variation.tag != VariationTag::new(b"NOPE"))); assert_eq!( first .iter() .find(|variation| variation.tag == VariationTag::new(b"wght")) .map(|variation| variation.value.0), Some(900.0) ); } fn render_variable_weight(weight: u16) -> ((f32, f32), u64, Vec) { let mut engine = TextEngine::new_with_web_fonts(&[WebFont { data: variable_font_fixture(), family: Some("Obscura VF Test".to_string()), weight: Some((100, 900)), italic: Some(false), }]); let tree = obscura_dom::parse_html("

MMMMMMMM

"); let copy = tree.get_element_by_id("copy").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Block; style.font_family = Some("Obscura VF Test".to_string()); style.font_weight = Some(weight.to_string()); style.font_size = Some(64.0); style.line_height = Some(crate::LineHeight::Px(80.0)); let styles = HashMap::from([(copy, style)]); let item = engine.try_build(&tree, copy, &styles).unwrap(); let geometry = engine.measure(item, Some(600.0)); engine.finalize(item, (0.0, 0.0), 600.0, None); let mut pixmap = tiny_skia::Pixmap::new(600, 100).unwrap(); engine.paint_item(item, &mut pixmap, (0.0, 0.0)); let ink = pixmap .pixels() .iter() .map(|pixel| u64::from(pixel.alpha())) .sum(); let mut cached_weights: Vec<_> = engine .variable_swash .images .keys() .filter_map(|key| { key.variations .iter() .find(|variation| variation.tag == VariationTag::new(b"wght")) .map(|variation| variation.value.0 as u16) }) .collect(); cached_weights.sort_unstable(); cached_weights.dedup(); (geometry, ink, cached_weights) } #[test] fn shaped_text_preserves_authored_alpha_through_mask_rasterization() { let render_alpha = |alpha: u8, underline: bool| { let tree = obscura_dom::parse_html("

Alpha

"); let copy = tree.get_element_by_id("copy").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Block; style.font_size = Some(32.0); style.line_height = Some(crate::LineHeight::Px(40.0)); style.color = Some([0, 0, 0, alpha]); style.underline = Some(underline); let styles = HashMap::from([(copy, style)]); let mut engine = TextEngine::new(); let item = engine.try_build(&tree, copy, &styles).unwrap(); engine.finalize(item, (0.0, 0.0), 160.0, None); let mut pixmap = tiny_skia::Pixmap::new(160, 48).unwrap(); engine.paint_item(item, &mut pixmap, (0.0, 0.0)); pixmap .pixels() .iter() .map(|pixel| u64::from(pixel.alpha())) .sum::() }; assert_eq!( render_alpha(0, true), 0, "transparent glyphs and decorations must not paint" ); let half = render_alpha(128, false); let opaque = render_alpha(255, false); let ratio = half * 100 / opaque; assert!( (48..=52).contains(&ratio), "50% CSS alpha must retain half the opaque coverage: half={half}, opaque={opaque}" ); } #[test] fn variable_wght_changes_shape_and_true_outline() { let (regular_geometry, regular_ink, regular_cache) = render_variable_weight(400); let (black_geometry, black_ink, black_cache) = render_variable_weight(900); assert!(regular_geometry.0 > 0.0 && black_geometry.0 > 0.0); assert!( black_ink > regular_ink * 13 / 10, "wght=900 should carry substantially more raster ink: {regular_ink} vs {black_ink}" ); assert_eq!(regular_cache, vec![400]); assert_eq!(black_cache, vec![900]); } #[test] fn variable_glyph_cache_keys_include_weight_axis() { let mut engine = TextEngine::new_with_web_fonts(&[WebFont { data: variable_font_fixture(), family: Some("Obscura VF Test".to_string()), weight: Some((100, 900)), italic: Some(false), }]); let tree = obscura_dom::parse_html( "

MM

", ); let copy = tree.get_element_by_id("copy").unwrap(); let regular = tree.get_element_by_id("regular").unwrap(); let black = tree.get_element_by_id("black").unwrap(); let mut base = LayoutStyle::default(); base.display = Display::Block; base.font_family = Some("Obscura VF Test".to_string()); base.font_size = Some(64.0); base.line_height = Some(crate::LineHeight::Px(80.0)); let mut regular_style = base.clone(); regular_style.display = Display::Inline; regular_style.font_weight = Some("400".to_string()); let mut black_style = regular_style.clone(); black_style.font_weight = Some("900".to_string()); let styles = HashMap::from([ (copy, base), (regular, regular_style), (black, black_style), ]); let item = engine.try_build(&tree, copy, &styles).unwrap(); engine.measure(item, Some(200.0)); engine.finalize(item, (0.0, 0.0), 200.0, None); let mut pixmap = tiny_skia::Pixmap::new(200, 100).unwrap(); engine.paint_item(item, &mut pixmap, (0.0, 0.0)); let weights: std::collections::HashSet<_> = engine .variable_swash .images .keys() .filter_map(|key| { key.variations .iter() .find(|variation| variation.tag == VariationTag::new(b"wght")) .map(|variation| variation.value.0 as u16) }) .collect(); assert_eq!(weights, std::collections::HashSet::from([400, 900])); } fn render_tailwind_optical_heading( optical_sizing: crate::FontOpticalSizing, explicit_opsz: Option, width: f32, ) -> ((f32, f32), usize, std::collections::HashSet) { let mut engine = TextEngine::new_with_web_fonts(&[WebFont { data: variable_font_fixture(), family: Some("Obscura VF Test".to_string()), weight: Some((100, 900)), italic: Some(false), }]); let tree = obscura_dom::parse_html( "

Build whatever you want, without touching your CSS file.

", ); let copy = tree.get_element_by_id("copy").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Block; style.font_family = Some("Obscura VF Test".to_string()); style.font_weight = Some("500".to_string()); style.font_size = Some(40.0); style.line_height = Some(crate::LineHeight::Px(40.0)); style.letter_spacing = Some(-2.0); style.letter_spacing_non_normal = Some(true); style.font_optical_sizing = Some(optical_sizing); style.font_variation_settings = Some( explicit_opsz .map(|value| { vec![crate::FontVariationSetting { tag: *b"opsz", value, }] }) .unwrap_or_default(), ); let styles = HashMap::from([(copy, style)]); let item = engine.try_build(&tree, copy, &styles).unwrap(); let geometry = engine.measure(item, Some(width)); let lines = engine.items[item].buffer.layout_runs().count(); engine.finalize(item, (0.0, 0.0), width, None); let mut pixmap = tiny_skia::Pixmap::new(512, 140).unwrap(); engine.paint_item(item, &mut pixmap, (0.0, 0.0)); let optical_values = engine .variable_swash .images .keys() .filter_map(|key| { key.variations .iter() .find(|variation| variation.tag == VariationTag::new(b"opsz")) .map(|variation| variation.value.0.to_bits()) }) .collect(); (geometry, lines, optical_values) } #[test] fn optical_sizing_changes_tailwind_heading_shape_and_raster_axes() { let (auto_geometry, auto_lines, auto_axes) = render_tailwind_optical_heading( crate::FontOpticalSizing::Auto, None, 496.0, ); let (none_geometry, none_lines, none_axes) = render_tailwind_optical_heading( crate::FontOpticalSizing::None, None, 496.0, ); let (explicit_geometry, explicit_lines, explicit_axes) = render_tailwind_optical_heading( crate::FontOpticalSizing::Auto, Some(14.0), 496.0, ); assert_ne!( auto_geometry, none_geometry, "automatic opsz must affect the shaped heading advances" ); assert_eq!(auto_lines, none_lines); assert_eq!(explicit_lines, none_lines); assert_ne!( explicit_geometry, auto_geometry, "an explicit low-level opsz coordinate must override automatic sizing" ); assert_eq!(auto_axes, std::collections::HashSet::from([32.0f32.to_bits()])); assert!(none_axes.is_empty()); assert_eq!( explicit_axes, std::collections::HashSet::from([14.0f32.to_bits()]) ); } #[test] fn text_only_inline_block_keeps_an_internal_shaping_context() { let tree = obscura_dom::parse_html( "ligature_name", ); let icon = tree.get_element_by_id("icon").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Inline; style.is_inline_block = true; let styles = HashMap::from([(icon, style)]); assert!( is_pure_text_ifc(&tree, icon, &styles), "atomic inline participation must not disable shaping inside the box" ); } #[test] fn inline_descendant_keeps_its_computed_font_metrics() { let tree = obscura_dom::parse_html( r#"
"#, ); let copy = tree.get_element_by_id("copy").unwrap(); let big = tree.get_element_by_id("big").unwrap(); let laid = crate::dom::layout_dom(&tree, (500.0, 200.0)); assert_eq!(laid.styles[&big].font_size, Some(32.0)); let item = laid.ifc_items[©]; let glyph_sizes = laid.text_engine.items[item] .buffer .layout_runs() .flat_map(|run| run.glyphs.iter().map(|glyph| glyph.font_size)) .collect::>(); assert!( glyph_sizes.iter().any(|size| (*size - 16.0).abs() < 0.01), "base text should shape at 16px: {glyph_sizes:?}" ); assert!( glyph_sizes.iter().any(|size| (*size - 32.0).abs() < 0.01), "inline descendant should shape at 32px: {glyph_sizes:?}" ); } #[test] fn preformatted_newlines_preserve_authored_line_count() { let tree = obscura_dom::parse_html( r#"
alpha
beta
gamma
alpha beta gamma
alpha beta gamma
"#, ); let laid = crate::dom::layout_dom(&tree, (400.0, 300.0)); let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()]; assert_eq!(laid.styles[&tree.get_element_by_id("ua").unwrap()].white_space, Some(crate::WhiteSpace::Pre)); assert_eq!(laid.styles[&tree.get_element_by_id("explicit").unwrap()].white_space, Some(crate::WhiteSpace::PreWrap)); assert!((rect("ua").height - 72.0).abs() < 0.01, "{:?}", rect("ua")); assert!( (rect("explicit").height - 72.0).abs() < 0.01, "{:?}", rect("explicit") ); assert!( (rect("normal").height - 24.0).abs() < 0.01, "{:?}", rect("normal") ); } fn shaped_line_texts(buffer: &Buffer) -> Vec { buffer .layout_runs() .map(|run| { let start = run .glyphs .iter() .map(|glyph| glyph.start) .min() .unwrap_or(0); let end = run .glyphs .iter() .map(|glyph| glyph.end) .max() .unwrap_or(start); run.text[start..end].trim().to_string() }) .collect() } #[test] fn text_indent_changes_only_the_first_formatted_line() { let tree = obscura_dom::parse_html( "

alpha beta gamma delta epsilon zeta eta theta

", ); let copy = tree.get_element_by_id("copy").unwrap(); let style = LayoutStyle { display: Display::Block, font_size: Some(16.0), line_height: Some(crate::LineHeight::Px(20.0)), text_indent: Some(Dimension::Px(40.0)), ..Default::default() }; let mut engine = TextEngine::new(); let item = engine .try_build(&tree, copy, &HashMap::from([(copy, style)])) .unwrap(); engine.finalize(item, (0.0, 0.0), 150.0, None); let lines = shaped_line_texts(&engine.items[item].buffer); assert!(lines.len() >= 2, "fixture must wrap: {lines:?}"); assert_eq!(engine.items[item].first_line_offset, 40.0); let runs: Vec<_> = engine.items[item].buffer.layout_runs().collect(); let first_x = runs[0].glyphs.first().unwrap().x + engine.items[item].first_line_offset; let second_x = runs[1].glyphs.first().unwrap().x; assert!( (first_x - 40.0).abs() < 0.01, "first line must start at the authored indent: {first_x}" ); assert!( second_x.abs() < 0.01, "continuation lines must return to the content edge: {second_x}" ); let percentage = LayoutStyle { display: Display::Block, text_indent: Some(Dimension::Percent(0.25)), ..Default::default() }; let percent_item = engine .try_build(&tree, copy, &HashMap::from([(copy, percentage)])) .unwrap(); engine.finalize(percent_item, (0.0, 0.0), 200.0, None); assert_eq!(engine.items[percent_item].first_line_offset, 50.0); } #[test] fn text_wrap_balance_changes_line_grouping_without_changing_line_count() { let tree = obscura_dom::parse_html("

Welcome to Mozilla

"); let hero = tree.get_element_by_id("hero").unwrap(); let style = LayoutStyle { display: Display::Block, font_size: Some(128.0), line_height: Some(crate::LineHeight::Px(128.0)), text_wrap_style: Some(crate::TextWrapStyle::Balance), ..Default::default() }; let mut engine = TextEngine::new(); let item = engine .try_build(&tree, hero, &HashMap::from([(hero, style)])) .unwrap(); let width = 912.0; engine.measure(item, Some(width)); let natural_lines = shaped_line_texts(&engine.items[item].buffer); engine.finalize(item, (0.0, 0.0), width, None); let balanced_lines = shaped_line_texts(&engine.items[item].buffer); let balanced_width = engine.items[item].buffer.size().0.unwrap(); assert_eq!(natural_lines, ["Welcome to", "Mozilla"]); assert_eq!(balanced_lines, ["Welcome", "to Mozilla"]); assert!( balanced_width < width - 1.0, "balance should tighten the effective wrap width: {balanced_width}" ); } #[test] fn text_wrap_style_is_inherited_and_can_be_reset() { let tree = obscura_dom::parse_html( r#"

balanced heading words

ordinary heading words

"#, ); let inherited = tree.get_element_by_id("inherited").unwrap(); let reset = tree.get_element_by_id("reset").unwrap(); let laid = crate::dom::layout_dom(&tree, (500.0, 300.0)); assert_eq!( laid.styles[&inherited].text_wrap_style, Some(crate::TextWrapStyle::Balance) ); assert_eq!( laid.styles[&reset].text_wrap_style, Some(crate::TextWrapStyle::Auto) ); } #[test] fn clip_fill_only_for_transparent_clip_text() { let mut s = LayoutStyle::default(); // Gradient + clip-to-text + transparent color: fills through the glyphs. s.background_clip_text = true; s.color = Some([0, 0, 0, 0]); s.background_gradient = Some((90.0, vec![(RED, None), (BLUE, None)])); assert!(clip_text_fill(&s).is_some()); // Same, but opaque text: paints normally, no clip fill. s.color = Some([10, 20, 30, 255]); assert!(clip_text_fill(&s).is_none()); // Clip-to-text off: ordinary transparent text stays invisible. s.color = Some([0, 0, 0, 0]); s.background_clip_text = false; assert!(clip_text_fill(&s).is_none()); // Solid background color becomes a flat two-stop gradient. s.background_clip_text = true; s.background_gradient = None; s.background_color = Some([12, 34, 56, 255]); let fill = clip_text_fill(&s).expect("solid bg clip fill"); assert_eq!(fill.1.len(), 2); assert_eq!(fill.1[0].0, [12, 34, 56, 255]); } #[test] fn sample_gradient_tints_left_to_right() { // 90deg (to right): left edge is the first stop, right edge the last. let fill = (90.0f32, vec![(RED, None), (BLUE, None)]); let left = sample_gradient(&fill, 0.0, 5.0, 100.0, 10.0); let right = sample_gradient(&fill, 100.0, 5.0, 100.0, 10.0); assert!(left[0] > left[2], "left end should be reddish: {left:?}"); assert!(right[2] > right[0], "right end should be bluish: {right:?}"); // A single-color list samples to that color everywhere. let flat = (0.0f32, vec![([7, 8, 9, 255], None)]); assert_eq!(sample_gradient(&flat, 3.0, 3.0, 20.0, 20.0), [7, 8, 9, 255]); } }

small large