Skip to main content

alpha2_text/
lib.rs

1//! Text shaping and rasterization for alpha2 hosts.
2//!
3//! Produces the engine's [`TextTexture`] in pure Rust from caller-supplied fonts. Paragraphs are
4//! split by direction (UAX #9), script and font, shaped with rustybuzz, broken per UAX #14 and
5//! rasterized with `ab_glyph_rasterizer` (color glyphs in `color`). The same request and fonts
6//! give the same pixels natively and in wasm, so browser preview and native export match.
7
8use std::cell::OnceCell;
9use std::cmp::Ordering;
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11use std::fmt;
12use std::ops::Range;
13use std::sync::Arc;
14
15use ab_glyph_rasterizer::{Point, Rasterizer, point};
16use alpha2_media::{
17    EmojiPolicy, HostTexture, HostTexturePixels, Premultiplication, RasterColorSpace, TextAlign,
18    TextTexture,
19};
20use rustybuzz::ttf_parser::{self, GlyphId, OutlineBuilder};
21use rustybuzz::{Direction, UnicodeBuffer};
22use unicode_bidi::ParagraphBidiInfo;
23use unicode_linebreak::BreakOpportunity;
24use unicode_script::{Script, UnicodeScript};
25use unicode_segmentation::UnicodeSegmentation;
26
27mod animated;
28mod color;
29mod gradient;
30mod unicode_tables;
31
32pub use animated::{ClusterInfo, ClusterState, ColorMix, LineInfo, TextLayout};
33pub use gradient::{TextGradient, TextGradientKind, TextGradientStop, TextSpread};
34
35const MAX_TEXT_BYTES: usize = 64 * 1024;
36const MAX_FONT_SIZE: f32 = 2_048.0;
37const MAX_LINE_HEIGHT: f32 = 10.0;
38/// Combined stroke width limit, in pixels.
39const MAX_STROKE_EXTENT: f32 = 64.0;
40const MAX_STROKES: usize = 4;
41const MAX_BACKGROUND_PADDING: f32 = 256.0;
42/// In font sizes.
43const LETTER_SPACING_RANGE: std::ops::RangeInclusive<f32> = -0.5..=1.0;
44
45/// One caller-owned font registered in fallback order.
46#[derive(Clone, Debug)]
47pub struct TextFont {
48    /// Stable family name used in cache keys and diagnostics.
49    pub family: String,
50    /// OpenType font bytes.
51    pub bytes: Arc<[u8]>,
52    /// Face index for font collections.
53    pub face_index: u32,
54}
55
56/// One text layout request.
57#[derive(Clone, Debug, PartialEq)]
58pub struct TextRasterRequest {
59    /// UTF-8 text shaped by the selected font fallback stack.
60    pub text: String,
61    /// Logical font size in pixels.
62    pub font_size: f32,
63    /// Maximum layout width. Non-positive values disable wrapping.
64    pub max_width: f32,
65    /// Baseline-to-baseline distance in font sizes. The em box (0.8 above the baseline, 0.2
66    /// below) is centered in lines taller than an em.
67    pub line_height: f32,
68    /// Per-line horizontal alignment.
69    pub align: TextAlign,
70    /// Glyph paint. Color glyphs keep their own colors except for text-color parts, which use
71    /// [`TextFill::foreground`], whose alpha is also their opacity.
72    pub fill: TextFill,
73    /// Whether color glyphs are drawn in color, and which fonts emoji take.
74    pub emoji_policy: EmojiPolicy,
75    /// Caller-managed version. Change it whenever content/style/font inputs change.
76    pub content_version: u64,
77    /// Outlines, innermost first, each drawn behind the previous. At most 4, reaching at most 64
78    /// pixels together.
79    pub strokes: Vec<TextStroke>,
80    /// A box behind each line, drawn behind the strokes.
81    pub background: Option<TextBackground>,
82    /// Draws the background into [`TextRasterResult::background`] instead of under the glyphs,
83    /// so a caller can draw something (a shadow) between them.
84    pub split_background: bool,
85    /// Preferred family, matched against any [`font_family_names`] entry after NFC and
86    /// lowercasing; the fallback stack fills missing glyphs. An unknown family keeps the fallback
87    /// order and is listed by [`TextRasterizer::unmatched_families`].
88    pub font_family: Option<String>,
89    /// Space after each grapheme cluster, like CSS `letter-spacing`, in `[-0.5, 1]` font sizes.
90    /// Not applied after a line's last cluster or within cursive scripts. Non-zero values disable
91    /// optional ligatures.
92    pub letter_spacing: f32,
93    /// Weight in `[1, 1000]`, matched per CSS Fonts 4 and applied to a variable face's `wght`
94    /// axis. Faces are never synthetically emboldened.
95    pub font_weight: u16,
96}
97
98/// What paints a text's glyphs.
99#[derive(Clone, Debug, PartialEq)]
100pub enum TextFill {
101    /// Straight (not premultiplied) sRGB RGBA.
102    Solid([u8; 4]),
103    /// A gradient over the layout box.
104    Gradient(TextGradient),
105}
106
107impl TextFill {
108    /// Color used for a color glyph's text-color parts: the solid color or the first stop.
109    #[must_use]
110    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
111    pub fn foreground(&self) -> [u8; 4] {
112        match self {
113            Self::Solid(color) => *color,
114            Self::Gradient(gradient) => gradient.stops.first().map_or([0; 4], |stop| {
115                stop.color
116                    .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
117            }),
118        }
119    }
120
121    fn is_valid(&self) -> bool {
122        match self {
123            Self::Solid(_) => true,
124            Self::Gradient(gradient) => gradient.is_valid(),
125        }
126    }
127}
128
129/// An outline band drawn behind the glyphs and earlier strokes.
130///
131/// Stroke `k` dilates glyph coverage by a disc whose radius is the sum of widths up to `k`, with
132/// the disc's last pixel weighted down for antialiasing.
133#[derive(Copy, Clone, Debug, PartialEq)]
134pub struct TextStroke {
135    /// Straight sRGB RGBA.
136    pub color: [u8; 4],
137    /// Positive width in pixels.
138    pub width: f32,
139}
140
141/// A box behind each non-empty line (advance by one em, grown by `padding`). Touching boxes
142/// merge.
143#[derive(Copy, Clone, Debug, PartialEq)]
144pub struct TextBackground {
145    /// Straight sRGB RGBA.
146    pub color: [u8; 4],
147    /// Space between the text and the box edge, in `[0, 256]` pixels.
148    pub padding: f32,
149}
150
151/// Rasterized pixels and their text-specific layout descriptor.
152#[derive(Clone, Debug, PartialEq)]
153pub struct TextRasterResult {
154    /// Layout facts sharing the generic compositor texture contract.
155    pub descriptor: TextTexture,
156    /// Tightly packed premultiplied RGBA8 pixels.
157    pub pixels: HostTexturePixels,
158    /// Background alone, same size as `pixels`, when
159    /// [`TextRasterRequest::split_background`] is set.
160    pub background: Option<Vec<u8>>,
161}
162
163/// Text geometry from [`TextRasterizer::measure`]. Rectangles are `[left, top, right, bottom]`
164/// in pixels from the layout box's top-left, which sits at ([`TextTexture::layout_x`],
165/// [`TextTexture::layout_y`]) in the rasterized texture.
166#[derive(Clone, Debug, PartialEq)]
167pub struct TextMeasure {
168    /// The wrap width, else the widest line, rounded up.
169    pub box_width: u32,
170    /// All lines' em boxes, rounded up.
171    pub box_height: u32,
172    /// [`TextRasterRequest::decoration_margin`].
173    pub decoration_margin: u32,
174    /// Horizontal extent of the line advances by the box height, grown by the decoration
175    /// margin. Unlike `ink`, it does not depend on glyph shapes.
176    pub content: [f32; 4],
177    /// Bounds of everything drawn (glyphs, strokes, background), at most a pixel loose (more for
178    /// COLR clip boxes). `None` when nothing is drawn.
179    pub ink: Option<[f32; 4]>,
180    /// Number of laid-out lines.
181    pub line_count: u32,
182    /// First alphabetic baseline, in pixels from the layout box's top.
183    pub first_baseline: f32,
184    /// Last alphabetic baseline, in pixels from the layout box's top.
185    pub last_baseline: f32,
186}
187
188/// Text shaped once ([`TextRasterizer::shape`]) so lines can be broken at any width without
189/// reshaping.
190#[derive(Clone, Debug)]
191pub struct ShapedText {
192    paragraphs: Vec<Vec<Cluster>>,
193    font_size: f32,
194    line_height: f32,
195    line_advance: f32,
196}
197
198/// Lines of a shaped text at one wrapping width ([`ShapedText::lines_at`]).
199#[derive(Copy, Clone, Debug, PartialEq)]
200pub struct LineMetrics {
201    /// Number of lines.
202    pub line_count: u32,
203    /// Widest line advance, excluding trailing spaces.
204    pub widest: f32,
205    /// Unrounded box height.
206    pub height: f32,
207    /// First alphabetic baseline, in pixels from the box's top.
208    pub first_baseline: f32,
209    /// Last alphabetic baseline, in pixels from the box's top.
210    pub last_baseline: f32,
211}
212
213impl ShapedText {
214    /// CSS min-content width: the widest unbreakable run.
215    #[must_use]
216    pub fn min_content_width(&self) -> f32 {
217        let mut widest = 0.0_f32;
218        for clusters in &self.paragraphs {
219            let mut start = 0;
220            for index in 1..=clusters.len() {
221                if index == clusters.len() || clusters[index].break_before {
222                    widest = widest.max(advance(&clusters[start..index]));
223                    start = index;
224                }
225            }
226        }
227        widest
228    }
229
230    /// CSS max-content width: the widest unwrapped paragraph.
231    #[must_use]
232    pub fn max_content_width(&self) -> f32 {
233        self.paragraphs
234            .iter()
235            .map(|clusters| advance(clusters))
236            .fold(0.0, f32::max)
237    }
238
239    /// Lines at `max_width`; a non-positive width does not wrap.
240    #[must_use]
241    pub fn lines_at(&self, max_width: f32) -> LineMetrics {
242        let (mut count, mut kept, mut widest) = (0_u32, 1_u32, 0.0_f32);
243        for clusters in &self.paragraphs {
244            for range in break_lines(clusters, max_width) {
245                count = count.saturating_add(1);
246                if !range.is_empty() {
247                    kept = count;
248                }
249                widest = widest.max(advance(&clusters[range]));
250            }
251        }
252        let [first_baseline, last_baseline] =
253            baselines(kept, self.font_size, self.line_height, self.line_advance);
254        LineMetrics {
255            line_count: kept,
256            widest,
257            height: box_height(kept, self.line_advance, self.font_size),
258            first_baseline,
259            last_baseline,
260        }
261    }
262}
263
264/// Line advance as [`break_lines`] sums it, excluding hanging clusters and the last cluster's
265/// letter spacing.
266fn advance(clusters: &[Cluster]) -> f32 {
267    let Some(last) = clusters.iter().rposition(|cluster| !cluster.hangs) else {
268        return 0.0;
269    };
270    clusters[..=last]
271        .iter()
272        .fold(0.0, |width, cluster| width + cluster.width)
273        - clusters[last].spacing
274}
275
276/// CSS half-leading above the em box, or zero when lines are closer than an em.
277#[must_use]
278pub fn half_leading(font_size: f32, line_height: f32) -> f32 {
279    (font_size * line_height - font_size).max(0.0) * 0.5
280}
281
282/// First and last baselines from the box's top, using a nominal ascent of 0.8 font sizes.
283#[allow(clippy::cast_precision_loss)]
284fn baselines(line_count: u32, font_size: f32, line_height: f32, line_advance: f32) -> [f32; 2] {
285    let (leading, ascent) = (half_leading(font_size, line_height), font_size * 0.8);
286    let last = line_count.saturating_sub(1) as f32;
287    [leading + ascent, last * line_advance + leading + ascent]
288}
289
290/// Box height covering every line's em box, including the last when lines overlap.
291#[allow(clippy::cast_precision_loss)]
292fn box_height(line_count: u32, line_advance: f32, font_size: f32) -> f32 {
293    line_count.saturating_sub(1) as f32 * line_advance + line_advance.max(font_size)
294}
295
296/// Typed text-host failure.
297#[derive(Clone, Debug, Eq, PartialEq)]
298#[non_exhaustive]
299pub enum TextRasterError {
300    /// No usable fonts were registered.
301    NoFonts,
302    /// OpenType parsing failed.
303    InvalidFont(String),
304    /// Request values are non-finite or outside the supported range.
305    InvalidRequest,
306    /// Computed dimensions cannot be represented safely.
307    TooLarge,
308}
309
310impl fmt::Display for TextRasterError {
311    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
312        match self {
313            Self::NoFonts => formatter.write_str("text rasterizer requires at least one font"),
314            Self::InvalidFont(error) => write!(formatter, "invalid text font: {error}"),
315            Self::InvalidRequest => formatter.write_str("invalid text raster request"),
316            Self::TooLarge => formatter.write_str("text raster result is too large"),
317        }
318    }
319}
320
321impl std::error::Error for TextRasterError {}
322
323#[derive(Clone, Debug)]
324struct LoadedFont {
325    source: TextFont,
326    /// Folded [`font_family`], used to group faces.
327    family: Option<String>,
328    /// Folded names in every language, matched against requested families.
329    aliases: Vec<String>,
330    /// OS/2 usWeightClass in [1, 1000]; 400 without the table.
331    weight: u16,
332    /// The fvar `wght` axis (min, default, max), for a variable face.
333    wght: Option<(f32, f32, f32)>,
334}
335
336const WGHT: ttf_parser::Tag = ttf_parser::Tag::from_bytes(b"wght");
337
338impl LoadedFont {
339    /// Validates the face and reads its family and weight; outlines are read lazily.
340    fn parse(source: TextFont) -> Result<Self, TextRasterError> {
341        let face = ttf_parser::Face::parse(&source.bytes, source.face_index)
342            .map_err(|error| TextRasterError::InvalidFont(error.to_string()))?;
343        let weight = face.weight().to_number().clamp(1, 1000);
344        let wght = face
345            .variation_axes()
346            .into_iter()
347            .find(|axis| axis.tag == WGHT)
348            .map(|axis| (axis.min_value, axis.def_value, axis.max_value));
349        let family = font_family(&source.bytes, source.face_index).map(|name| fold_family(&name));
350        let mut aliases = font_family_names(&source.bytes, source.face_index)
351            .iter()
352            .map(|name| fold_family(name))
353            .collect::<Vec<_>>();
354        aliases.extend(family.clone());
355        aliases.sort();
356        aliases.dedup();
357        Ok(Self {
358            source,
359            family,
360            aliases,
361            weight,
362            wght,
363        })
364    }
365
366    /// Weight range: the `wght` axis rounded inward, or the static weight twice.
367    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
368    fn weights(&self) -> (u16, u16) {
369        self.wght
370            .map_or((self.weight, self.weight), |(min, _, max)| {
371                let whole = |value: f32| value.clamp(1.0, 1000.0) as u16;
372                (whole(min.ceil()), whole(max.floor()).max(whole(min.ceil())))
373            })
374    }
375}
376
377/// Face indices in CSS Fonts 4 (section 5.2) weight matching order for `desired`, ties in input
378/// order. A range containing `desired` matches exactly; otherwise its nearest end counts.
379fn weight_order(weights: &[(u16, u16)], desired: u16) -> Vec<usize> {
380    let key = |&(min, max): &(u16, u16)| -> (u8, u16) {
381        if (min..=max).contains(&desired) {
382            return (0, 0);
383        }
384        let weight = if desired < min { min } else { max };
385        let (above, distance) = (weight > desired, weight.abs_diff(desired));
386        if (400..=500).contains(&desired) {
387            match (above, weight <= 500) {
388                (true, true) => (1, distance),
389                (false, _) => (2, distance),
390                (true, false) => (3, distance),
391            }
392        } else if (desired < 400) != above {
393            (1, distance)
394        } else {
395            (2, distance)
396        }
397    };
398    let mut order = (0..weights.len()).collect::<Vec<_>>();
399    order.sort_by_key(|&index| key(&weights[index]));
400    order
401}
402
403/// Checks that `bytes` hold an OpenType face at `face_index`, as [`TextRasterizer::add_font`]
404/// would, so a host can reject a file at import.
405///
406/// # Errors
407/// Returns [`TextRasterError::InvalidFont`] with the parser's reason.
408pub fn check_font(bytes: &[u8], face_index: u32) -> Result<(), TextRasterError> {
409    ttf_parser::Face::parse(bytes, face_index)
410        .map(|_| ())
411        .map_err(|error| TextRasterError::InvalidFont(error.to_string()))
412}
413
414/// Number of faces in a font collection (`.ttc`, `.otc`), else 1.
415#[must_use]
416pub fn face_count(bytes: &[u8]) -> u32 {
417    ttf_parser::fonts_in_collection(bytes).unwrap_or(1)
418}
419
420/// The face's typographic family (name ID 16), else family (name ID 1), preferring US English.
421/// `None` for unparsable bytes or no family name.
422#[must_use]
423pub fn font_family(bytes: &[u8], face_index: u32) -> Option<String> {
424    use rustybuzz::ttf_parser::{Language, name_id};
425    let face = rustybuzz::ttf_parser::Face::parse(bytes, face_index).ok()?;
426    let names = face.names();
427    [name_id::TYPOGRAPHIC_FAMILY, name_id::FAMILY]
428        .into_iter()
429        .find_map(|id| {
430            let decoded = || {
431                names
432                    .into_iter()
433                    .filter(move |name| name.name_id == id)
434                    .filter_map(|name| {
435                        let text = name.to_string()?;
436                        (!text.trim().is_empty()).then_some((name.language(), text))
437                    })
438            };
439            decoded()
440                .find(|(language, _)| *language == Language::English_UnitedStates)
441                .or_else(|| decoded().next())
442                .map(|(_, text)| text)
443        })
444}
445
446/// Every distinct name ID 16 and 1 family name in every language, in table order. A project may
447/// name a family by any of them. Empty for unparsable bytes.
448#[must_use]
449pub fn font_family_names(bytes: &[u8], face_index: u32) -> Vec<String> {
450    use rustybuzz::ttf_parser::name_id;
451    let Ok(face) = rustybuzz::ttf_parser::Face::parse(bytes, face_index) else {
452        return Vec::new();
453    };
454    let mut names = Vec::new();
455    for name in face.names() {
456        if name.name_id != name_id::TYPOGRAPHIC_FAMILY && name.name_id != name_id::FAMILY {
457            continue;
458        }
459        if let Some(text) = name.to_string().map(|text| text.trim().to_owned())
460            && !text.is_empty()
461            && !names.contains(&text)
462        {
463            names.push(text);
464        }
465    }
466    names
467}
468
469/// Normalizes a family name for comparison: NFC, then Unicode lowercase.
470#[must_use]
471pub fn fold_family(name: &str) -> String {
472    use unicode_normalization::UnicodeNormalization;
473    name.trim().nfc().collect::<String>().to_lowercase()
474}
475
476/// Default result cache budget in bytes: a few full-frame 4K titles.
477pub const DEFAULT_TEXT_CACHE_BYTES: usize = 64 << 20;
478
479/// Per-entry cost so that empty results still bound the entry count.
480const CACHE_ENTRY_OVERHEAD: usize = 256;
481
482/// Deterministic text host with version-key result caching.
483///
484/// Results are LRU-cached by content version within a byte budget
485/// ([`DEFAULT_TEXT_CACHE_BYTES`] by default) and shared as the same [`Arc`].
486#[derive(Debug)]
487pub struct TextRasterizer {
488    fonts: Vec<LoadedFont>,
489    cache: ResultCache,
490    tiles: animated::TileCache,
491    unmatched: std::sync::Mutex<BTreeSet<String>>,
492}
493
494#[derive(Debug)]
495struct ResultCache {
496    entries: HashMap<u64, CacheEntry>,
497    /// Content versions by last use, oldest first.
498    by_use: BTreeMap<u64, u64>,
499    next_use: u64,
500    bytes: usize,
501    budget: usize,
502}
503
504#[derive(Debug)]
505struct CacheEntry {
506    result: Arc<TextRasterResult>,
507    last_use: u64,
508    bytes: usize,
509}
510
511impl ResultCache {
512    fn new(budget: usize) -> Self {
513        Self {
514            entries: HashMap::new(),
515            by_use: BTreeMap::new(),
516            next_use: 0,
517            bytes: 0,
518            budget,
519        }
520    }
521
522    fn get(&mut self, version: u64) -> Option<Arc<TextRasterResult>> {
523        let entry = self.entries.get_mut(&version)?;
524        self.by_use.remove(&entry.last_use);
525        entry.last_use = self.next_use;
526        self.by_use.insert(self.next_use, version);
527        self.next_use += 1;
528        Some(Arc::clone(&entry.result))
529    }
530
531    fn insert(&mut self, version: u64, result: &Arc<TextRasterResult>) {
532        self.remove(version);
533        let bytes = result
534            .pixels
535            .rgba8
536            .len()
537            .saturating_add(CACHE_ENTRY_OVERHEAD);
538        if bytes > self.budget {
539            return;
540        }
541        self.bytes += bytes;
542        self.entries.insert(
543            version,
544            CacheEntry {
545                result: Arc::clone(result),
546                last_use: self.next_use,
547                bytes,
548            },
549        );
550        self.by_use.insert(self.next_use, version);
551        self.next_use += 1;
552        self.evict();
553    }
554
555    fn remove(&mut self, version: u64) {
556        if let Some(entry) = self.entries.remove(&version) {
557            self.by_use.remove(&entry.last_use);
558            self.bytes -= entry.bytes;
559        }
560    }
561
562    fn clear(&mut self) {
563        self.entries.clear();
564        self.by_use.clear();
565        self.bytes = 0;
566    }
567
568    fn set_budget(&mut self, budget: usize) {
569        self.budget = budget;
570        self.evict();
571    }
572
573    fn evict(&mut self) {
574        while self.bytes > self.budget {
575            let Some((_, version)) = self.by_use.pop_first() else {
576                break;
577            };
578            if let Some(entry) = self.entries.remove(&version) {
579                self.bytes -= entry.bytes;
580            }
581        }
582    }
583}
584
585impl TextRasterizer {
586    /// A rasterizer with no fonts yet; add them with [`Self::add_font`].
587    #[must_use]
588    pub fn empty() -> Self {
589        Self {
590            fonts: Vec::new(),
591            cache: ResultCache::new(DEFAULT_TEXT_CACHE_BYTES),
592            tiles: animated::TileCache::default(),
593            unmatched: std::sync::Mutex::default(),
594        }
595    }
596
597    /// Takes a fallback-ordered OpenType stack.
598    ///
599    /// # Errors
600    /// Returns [`TextRasterError::NoFonts`] or the first malformed font.
601    pub fn new(fonts: Vec<TextFont>) -> Result<Self, TextRasterError> {
602        if fonts.is_empty() {
603            return Err(TextRasterError::NoFonts);
604        }
605        let loaded = fonts
606            .into_iter()
607            .map(LoadedFont::parse)
608            .collect::<Result<Vec<_>, _>>()?;
609        Ok(Self {
610            fonts: loaded,
611            cache: ResultCache::new(DEFAULT_TEXT_CACHE_BYTES),
612            tiles: animated::TileCache::default(),
613            unmatched: std::sync::Mutex::default(),
614        })
615    }
616
617    /// Appends a font to the end of the fallback stack.
618    ///
619    /// Clears the result cache.
620    ///
621    /// # Errors
622    /// Returns [`TextRasterError::InvalidFont`] for malformed font bytes.
623    pub fn add_font(&mut self, source: TextFont) -> Result<(), TextRasterError> {
624        self.fonts.push(LoadedFont::parse(source)?);
625        self.cache.clear();
626        self.tiles.clear();
627        Ok(())
628    }
629
630    /// Number of fonts in the fallback stack.
631    #[must_use]
632    pub fn font_count(&self) -> usize {
633        self.fonts.len()
634    }
635
636    /// Requested families no registered font had, sorted, as spelled. Adding fonts later does not
637    /// clear the list.
638    #[must_use]
639    pub fn unmatched_families(&self) -> Vec<String> {
640        self.unmatched
641            .lock()
642            .unwrap_or_else(std::sync::PoisonError::into_inner)
643            .iter()
644            .cloned()
645            .collect()
646    }
647
648    /// Shapes, lays out and rasterizes one request, or returns the cached result for its version.
649    ///
650    /// # Errors
651    /// Returns a typed validation, font, or size error.
652    pub fn rasterize(
653        &mut self,
654        request: &TextRasterRequest,
655    ) -> Result<Arc<TextRasterResult>, TextRasterError> {
656        validate_request(request)?;
657        if self.fonts.is_empty() {
658            return Err(TextRasterError::NoFonts);
659        }
660        if let Some(cached) = self.cache.get(request.content_version) {
661            return Ok(cached);
662        }
663        let result = Arc::new(self.rasterize_uncached(request)?);
664        self.cache.insert(request.content_version, &result);
665        Ok(result)
666    }
667
668    /// Drops a single host version after content or font invalidation.
669    pub fn invalidate(&mut self, content_version: u64) {
670        self.cache.remove(content_version);
671    }
672
673    /// Sets the result cache budget in bytes, evicting down to it. Results larger than the
674    /// budget are returned but not kept.
675    pub fn set_cache_budget(&mut self, bytes: usize) {
676        self.cache.set_budget(bytes);
677    }
678
679    /// Bytes the cached results count against the budget.
680    #[must_use]
681    pub fn cache_bytes(&self) -> usize {
682        self.cache.bytes
683    }
684
685    /// Drops every rasterized result while retaining parsed fonts.
686    pub fn clear(&mut self) {
687        self.cache.clear();
688        self.tiles.clear();
689    }
690
691    /// Number of versioned raster results currently retained.
692    #[must_use]
693    pub fn cache_len(&self) -> usize {
694        self.cache.entries.len()
695    }
696
697    /// Measures `request` as [`Self::rasterize`] would lay it out, without drawing or caching.
698    ///
699    /// # Errors
700    /// Returns the same errors as [`Self::rasterize`].
701    #[allow(clippy::cast_precision_loss)]
702    pub fn measure(&self, request: &TextRasterRequest) -> Result<TextMeasure, TextRasterError> {
703        validate_request(request)?;
704        if self.fonts.is_empty() {
705            return Err(TextRasterError::NoFonts);
706        }
707        let faces = Faces::new(&self.fonts, request.font_weight)?;
708        let layout = self.lay_out(&faces, request)?;
709        let mut ink = None;
710        layout.each_glyph(
711            &faces,
712            request,
713            &mut HashMap::new(),
714            |drawing, origin, scale, _| {
715                if let Some([x_min, y_min, x_max, y_max]) = drawing.ink_bounds() {
716                    include(
717                        &mut ink,
718                        [
719                            origin[0] + x_min * scale,
720                            origin[1] - y_max * scale,
721                            origin[0] + x_max * scale,
722                            origin[1] - y_min * scale,
723                        ],
724                    );
725                }
726            },
727        );
728        // The outermost band reaches the disc radius plus one rim pixel past the ink.
729        let band = stroke_radius(&request.strokes).map_or(0.0, |radius| radius + 1.0);
730        ink = ink.map(|bounds| grown(bounds, band));
731        let padding = request.background.map(|background| background.padding);
732        let mut content = None;
733        for (index, line) in layout.lines.iter().enumerate() {
734            let left = layout.line_offset(line, request.align);
735            let top = layout.line_top(index);
736            include(&mut content, [left, 0.0, left + line.width, layout.height]);
737            if let (Some(padding), true) = (padding, line.width > 0.0) {
738                include(
739                    &mut ink,
740                    grown(
741                        [left, top, left + line.width, top + request.font_size],
742                        padding,
743                    ),
744                );
745            }
746        }
747        let content = content.unwrap_or([0.0, 0.0, 0.0, layout.height]);
748        let last = layout.lines.len().saturating_sub(1);
749        Ok(TextMeasure {
750            first_baseline: layout.line_top(0) + layout.ascent,
751            last_baseline: layout.line_top(last) + layout.ascent,
752            box_width: layout.box_width,
753            box_height: layout.box_height,
754            decoration_margin: request.decoration_margin(),
755            content: grown(content, request.decoration_margin() as f32),
756            ink,
757            line_count: layout.line_count,
758        })
759    }
760
761    /// Shapes `request` as [`Self::rasterize`] would, so lines can be broken at any width later.
762    /// `max_width`, fill, strokes, background and content version are ignored.
763    ///
764    /// # Errors
765    /// Returns [`TextRasterError::NoFonts`] without fonts, and
766    /// [`TextRasterError::InvalidRequest`] for values [`Self::rasterize`] rejects.
767    pub fn shape(&self, request: &TextRasterRequest) -> Result<ShapedText, TextRasterError> {
768        validate_request(&TextRasterRequest {
769            text: String::new(),
770            max_width: 0.0,
771            content_version: 1,
772            fill: TextFill::Solid([0; 4]),
773            strokes: Vec::new(),
774            background: None,
775            ..request.clone()
776        })?;
777        if request.text.len() > MAX_TEXT_BYTES {
778            return Err(TextRasterError::InvalidRequest);
779        }
780        if self.fonts.is_empty() {
781            return Err(TextRasterError::NoFonts);
782        }
783        let faces = Faces::new(&self.fonts, request.font_weight)?;
784        let order = self.font_order(request.font_family.as_deref(), request.font_weight);
785        let paragraphs = normalize(&request.text, request.emoji_policy)
786            .split('\n')
787            .map(|paragraph| {
788                let bidi = ParagraphBidiInfo::new(paragraph, None);
789                let glyphs = shape_paragraph(
790                    &faces,
791                    paragraph,
792                    &bidi,
793                    request.font_size,
794                    &order,
795                    request.emoji_policy,
796                    request.letter_spacing,
797                );
798                clusters(&glyphs, paragraph, request.letter_spacing)
799            })
800            .collect();
801        Ok(ShapedText {
802            paragraphs,
803            font_size: request.font_size,
804            line_height: request.line_height,
805            line_advance: request.font_size * request.line_height,
806        })
807    }
808
809    #[allow(clippy::cast_precision_loss)]
810    fn lay_out(
811        &self,
812        faces: &Faces<'_>,
813        request: &TextRasterRequest,
814    ) -> Result<Layout, TextRasterError> {
815        let line_advance = request.font_size * request.line_height;
816        let order = self.font_order(request.font_family.as_deref(), request.font_weight);
817        let mut lines = Vec::new();
818        // Trailing blank lines take no room.
819        let mut kept = 1;
820        let mut clusters = Vec::new();
821        let mut paragraph_start = 0;
822        for paragraph in normalize(&request.text, request.emoji_policy).split('\n') {
823            let first = clusters.len();
824            for line in layout_paragraph(
825                faces,
826                paragraph,
827                request.font_size,
828                request.max_width,
829                &order,
830                request.emoji_policy,
831                request.letter_spacing,
832                (first, lines.len()),
833                &mut clusters,
834            ) {
835                lines.push(line);
836                if lines.last().is_some_and(|line| line.has_glyphs) {
837                    kept = lines.len();
838                }
839            }
840            for span in &mut clusters[first..] {
841                span.bytes = span.bytes.start + paragraph_start..span.bytes.end + paragraph_start;
842            }
843            paragraph_start += paragraph.len() + 1;
844        }
845        lines.truncate(kept);
846        let line_count = u32::try_from(lines.len()).map_err(|_| TextRasterError::TooLarge)?;
847        let width = if request.max_width > 0.0 {
848            request.max_width
849        } else {
850            lines.iter().map(|line| line.width).fold(0.0, f32::max)
851        }
852        .max(1.0);
853        let height = box_height(line_count, line_advance, request.font_size).max(1.0);
854        Ok(Layout {
855            box_width: checked_dimension(width)?,
856            box_height: checked_dimension(height)?,
857            lines,
858            clusters,
859            line_count,
860            line_advance,
861            half_leading: half_leading(request.font_size, request.line_height),
862            ascent: request.font_size * 0.8,
863            descent: request.font_size * 0.2,
864            width,
865            height,
866        })
867    }
868
869    #[allow(
870        clippy::cast_possible_truncation,
871        clippy::cast_precision_loss,
872        clippy::too_many_lines
873    )]
874    fn rasterize_uncached(
875        &self,
876        request: &TextRasterRequest,
877    ) -> Result<TextRasterResult, TextRasterError> {
878        let faces = Faces::new(&self.fonts, request.font_weight)?;
879        let layout = self.lay_out(&faces, request)?;
880        let (box_width, box_height) = (layout.box_width, layout.box_height);
881        let mut drawings = HashMap::<(usize, u16), Drawing>::new();
882        let mut placed = Vec::new();
883        let (mut overflow_x, mut overflow_y) = (0_i64, 0_i64);
884        let (mut ink_ascent, mut ink_descent) = (layout.ascent, layout.descent);
885        layout.each_glyph(
886            &faces,
887            request,
888            &mut drawings,
889            |drawing, origin, scale, glyph| {
890                let Some(bounds) = drawing.bounds() else {
891                    return;
892                };
893                let pixels = PixelBox::of(bounds, origin, scale);
894                overflow_x = overflow_x
895                    .max(pixels.left.saturating_neg())
896                    .max(pixels.right.saturating_sub(i64::from(box_width)));
897                overflow_y = overflow_y
898                    .max(pixels.top.saturating_neg())
899                    .max(pixels.bottom.saturating_sub(i64::from(box_height)));
900                ink_ascent = ink_ascent.max(bounds[3] * scale + glyph.y_offset);
901                ink_descent = ink_descent.max(-(bounds[1] * scale + glyph.y_offset));
902                placed.push(((glyph.font, glyph.glyph_index), origin, scale, pixels));
903            },
904        );
905        // A symmetric margin keeps the box centered and leaves room for decorations and overflow.
906        let (outline, padding) = decoration_extents(request);
907        let margin = |overflow: i64| -> Result<u32, TextRasterError> {
908            u32::try_from(overflow)
909                .map_err(|_| TextRasterError::TooLarge)?
910                .checked_add(outline)
911                .map(|margin| margin.max(outline.max(padding)))
912                .ok_or(TextRasterError::TooLarge)
913        };
914        let (margin_x, margin_y) = (margin(overflow_x)?, margin(overflow_y)?);
915        let width = margin_x
916            .checked_mul(2)
917            .and_then(|margins| box_width.checked_add(margins))
918            .ok_or(TextRasterError::TooLarge)?;
919        let height = margin_y
920            .checked_mul(2)
921            .and_then(|margins| box_height.checked_add(margins))
922            .ok_or(TextRasterError::TooLarge)?;
923        if width > 16_384 || height > 16_384 {
924            return Err(TextRasterError::TooLarge);
925        }
926        let pixel_count = usize::try_from(width)
927            .ok()
928            .and_then(|width| {
929                usize::try_from(height)
930                    .ok()
931                    .and_then(|height| width.checked_mul(height))
932            })
933            .ok_or(TextRasterError::TooLarge)?;
934        let mut coverage = vec![0_u8; pixel_count];
935        // Allocated only once a color glyph is placed.
936        let mut colors: Option<Vec<u8>> = None;
937        let mut line_boxes = Vec::with_capacity(layout.lines.len());
938        for (index, line) in layout.lines.iter().enumerate() {
939            if line.width > 0.0 {
940                line_boxes.push((
941                    layout.line_offset(line, request.align),
942                    line.width,
943                    layout.line_top(index),
944                ));
945            }
946        }
947        for (key, origin, scale, pixels) in placed {
948            let (origin_x, origin_y) = (
949                pixels.left + i64::from(margin_x),
950                pixels.top + i64::from(margin_y),
951            );
952            let glyph_width = usize::try_from(pixels.right - pixels.left).unwrap_or(0);
953            match &drawings[&key] {
954                Drawing::Outline(outline) => place_glyph(
955                    &mut coverage,
956                    width,
957                    height,
958                    origin_x,
959                    origin_y,
960                    glyph_width,
961                    &outline.draw(origin, scale, pixels),
962                ),
963                drawing => place_color_glyph(
964                    colors.get_or_insert_with(|| vec![0; pixel_count * 4]),
965                    width,
966                    height,
967                    origin_x,
968                    origin_y,
969                    glyph_width,
970                    &drawing.draw_color(origin, scale, pixels),
971                ),
972            }
973        }
974        let bands = stroke_bands(request, &coverage, colors.as_deref(), width);
975        let (rgba8, background) = compose(
976            request,
977            &coverage,
978            colors.as_deref(),
979            width,
980            [margin_x, margin_y],
981            &line_boxes,
982            &bands,
983            &RequestLook::of(request, [layout.width, layout.height]),
984        );
985        let texture = HostTexture {
986            content_version: request.content_version,
987            width,
988            height,
989            premultiplication: Premultiplication::Premultiplied,
990            color_space: RasterColorSpace::Srgb,
991        };
992        Ok(TextRasterResult {
993            descriptor: TextTexture {
994                texture: texture.clone(),
995                layout_x: margin_x as f32,
996                layout_y: margin_y as f32,
997                layout_width: layout.width,
998                layout_height: layout.height,
999                first_baseline: layout.half_leading + layout.ascent,
1000                ascent: ink_ascent,
1001                descent: ink_descent,
1002                line_count: layout.line_count,
1003            },
1004            pixels: HostTexturePixels { texture, rgba8 },
1005            background,
1006        })
1007    }
1008
1009    /// Font try order: `family` first, then families in registration order; within a family,
1010    /// faces by [`weight_order`].
1011    fn font_order(&self, family: Option<&str>, weight: u16) -> Vec<usize> {
1012        // A face without a family is its own group.
1013        let mut groups: Vec<(Option<&str>, Vec<usize>)> = Vec::new();
1014        for (index, font) in self.fonts.iter().enumerate() {
1015            let name = font.family.as_deref();
1016            match groups
1017                .iter_mut()
1018                .find(|(held, _)| name.is_some() && *held == name)
1019            {
1020                Some((_, faces)) => faces.push(index),
1021                None => groups.push((name, vec![index])),
1022            }
1023        }
1024        if let Some(requested) = family {
1025            let wanted = fold_family(requested);
1026            match groups.iter().position(|(_, faces)| {
1027                faces
1028                    .iter()
1029                    .any(|&index| self.fonts[index].aliases.contains(&wanted))
1030            }) {
1031                Some(at) => {
1032                    let preferred = groups.remove(at);
1033                    groups.insert(0, preferred);
1034                }
1035                None if !self.fonts.is_empty() => {
1036                    self.unmatched
1037                        .lock()
1038                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1039                        .insert(requested.to_owned());
1040                }
1041                None => {}
1042            }
1043        }
1044        groups
1045            .into_iter()
1046            .flat_map(|(_, faces)| {
1047                let weights = faces
1048                    .iter()
1049                    .map(|&index| self.fonts[index].weights())
1050                    .collect::<Vec<_>>();
1051                weight_order(&weights, weight)
1052                    .into_iter()
1053                    .map(move |at| faces[at])
1054            })
1055            .collect()
1056    }
1057}
1058
1059/// Faces parsed once per request; shaping tables load lazily.
1060struct Faces<'a> {
1061    parsed: Vec<ttf_parser::Face<'a>>,
1062    shapers: Vec<OnceCell<rustybuzz::Face<'a>>>,
1063    /// Has a COLR, CBDT or sbix table.
1064    color: Vec<bool>,
1065}
1066
1067impl<'a> Faces<'a> {
1068    /// Variable faces are set to `weight`, clamped to their axis.
1069    fn new(fonts: &'a [LoadedFont], weight: u16) -> Result<Self, TextRasterError> {
1070        let parsed = fonts
1071            .iter()
1072            .map(|font| {
1073                let mut face = ttf_parser::Face::parse(&font.source.bytes, font.source.face_index)
1074                    .ok()
1075                    .filter(|face| face.units_per_em() > 0)
1076                    .ok_or_else(|| TextRasterError::InvalidFont(font.source.family.clone()))?;
1077                if let Some((min, _, max)) = font.wght {
1078                    face.set_variation(WGHT, f32::from(weight).clamp(min, max));
1079                }
1080                Ok(face)
1081            })
1082            .collect::<Result<Vec<_>, _>>()?;
1083        Ok(Self {
1084            shapers: parsed.iter().map(|_| OnceCell::new()).collect(),
1085            color: parsed
1086                .iter()
1087                .map(|face| {
1088                    let tables = face.tables();
1089                    tables.colr.is_some() || tables.cbdt.is_some() || tables.sbix.is_some()
1090                })
1091                .collect(),
1092            parsed,
1093        })
1094    }
1095
1096    fn shaper(&self, font: usize) -> &rustybuzz::Face<'a> {
1097        self.shapers[font].get_or_init(|| rustybuzz::Face::from_face(self.parsed[font].clone()))
1098    }
1099
1100    #[allow(clippy::cast_precision_loss)]
1101    fn units_per_em(&self, font: usize) -> f32 {
1102        f32::from(self.parsed[font].units_per_em())
1103    }
1104
1105    fn has(&self, font: usize, character: char) -> bool {
1106        self.parsed[font]
1107            .glyph_index(character)
1108            .is_some_and(|glyph| glyph.0 != 0)
1109    }
1110
1111    /// Whether the font has every visible character of `grapheme`.
1112    fn covers(&self, font: usize, grapheme: &str) -> bool {
1113        grapheme
1114            .chars()
1115            .filter(|&character| !is_default_ignorable(character))
1116            .all(|character| self.has(font, character))
1117    }
1118}
1119
1120/// Replaces emoji with a replacement character under [`EmojiPolicy::Replace`], every line or
1121/// paragraph separator (U+2028 included) with `\n`, and tabs with spaces.
1122fn normalize(text: &str, policy: EmojiPolicy) -> String {
1123    normalize_mapped(text, policy, None)
1124}
1125
1126/// [`normalize`], optionally filling `map` with the source byte of each output byte (grapheme
1127/// start for replaced ones) plus a final entry for `text`'s length.
1128fn normalize_mapped(text: &str, policy: EmojiPolicy, mut map: Option<&mut Vec<usize>>) -> String {
1129    let mut normalized = String::with_capacity(text.len());
1130    for (start, grapheme) in text.grapheme_indices(true) {
1131        let before = normalized.len();
1132        if policy == EmojiPolicy::Replace && is_emoji(grapheme) {
1133            normalized.push('\u{fffd}');
1134        } else if grapheme == "\r\n" {
1135            normalized.push('\n');
1136        } else {
1137            normalized.extend(grapheme.chars().map(|character| match character {
1138                '\r'
1139                | '\u{b}'
1140                | '\u{c}'
1141                | '\u{1c}'..='\u{1e}'
1142                | '\u{85}'
1143                | '\u{2028}'
1144                | '\u{2029}' => '\n',
1145                '\t' => ' ',
1146                other => other,
1147            }));
1148        }
1149        if let Some(map) = map.as_deref_mut() {
1150            // A grapheme that changed length maps to its start.
1151            let written = normalized.len() - before;
1152            if written == grapheme.len() {
1153                map.extend(start..start + written);
1154            } else {
1155                map.extend(std::iter::repeat_n(start, written));
1156            }
1157        }
1158    }
1159    if let Some(map) = map {
1160        map.push(text.len());
1161    }
1162    normalized
1163}
1164
1165/// Emoji presentation per UTS #51, matching `OffscreenTextRasterizer`: an `Emoji_Presentation`
1166/// character, or U+FE0F with a pictograph or keycap, unless U+FE0E asks for text.
1167fn is_emoji(grapheme: &str) -> bool {
1168    if grapheme.contains('\u{fe0e}') {
1169        return false;
1170    }
1171    let selected = grapheme.contains('\u{fe0f}');
1172    grapheme.chars().any(|character| {
1173        in_table(unicode_tables::EMOJI_PRESENTATION, character)
1174            || (selected
1175                && (character == '\u{20e3}'
1176                    || in_table(unicode_tables::EXTENDED_PICTOGRAPHIC, character)))
1177    })
1178}
1179
1180/// Whether `text`'s first non-common script is cursive per CSS Text 3 (no letter spacing).
1181fn is_cursive(text: &str) -> bool {
1182    text.chars()
1183        .map(|character| character.script())
1184        .find(|script| !matches!(script, Script::Common | Script::Inherited | Script::Unknown))
1185        .is_some_and(|script| {
1186            matches!(
1187                script,
1188                Script::Adlam
1189                    | Script::Arabic
1190                    | Script::Hanifi_Rohingya
1191                    | Script::Mandaic
1192                    | Script::Mongolian
1193                    | Script::Nko
1194                    | Script::Phags_Pa
1195                    | Script::Syriac
1196            )
1197        })
1198}
1199
1200fn is_default_ignorable(character: char) -> bool {
1201    in_table(unicode_tables::DEFAULT_IGNORABLE, character)
1202}
1203
1204fn in_table(table: &[(u32, u32)], character: char) -> bool {
1205    let code = u32::from(character);
1206    table
1207        .binary_search_by(|&(first, last)| {
1208            if last < code {
1209                Ordering::Less
1210            } else if first > code {
1211                Ordering::Greater
1212            } else {
1213                Ordering::Equal
1214            }
1215        })
1216        .is_ok()
1217}
1218
1219/// A space that hangs at a line end. Excludes no-break spaces and the visible Ogham space mark.
1220fn hangs(character: char) -> bool {
1221    character.is_whitespace()
1222        && !matches!(character, '\u{a0}' | '\u{2007}' | '\u{202f}' | '\u{1680}')
1223}
1224
1225fn is_hangul(character: char) -> bool {
1226    matches!(
1227        character,
1228        '\u{1100}'..='\u{11ff}'
1229            | '\u{3130}'..='\u{318f}'
1230            | '\u{a960}'..='\u{a97f}'
1231            | '\u{ac00}'..='\u{d7a3}'
1232            | '\u{d7b0}'..='\u{d7ff}'
1233    )
1234}
1235
1236/// Broken and placed lines, in pixels from the layout box's top-left, y down.
1237struct Layout {
1238    lines: Vec<LaidLine>,
1239    /// Logical order, bytes in the normalized text.
1240    clusters: Vec<ClusterSpan>,
1241    line_count: u32,
1242    line_advance: f32,
1243    /// See [`half_leading`].
1244    half_leading: f32,
1245    ascent: f32,
1246    descent: f32,
1247    /// The wrap width, else the widest line.
1248    width: f32,
1249    height: f32,
1250    box_width: u32,
1251    box_height: u32,
1252}
1253
1254impl Layout {
1255    /// Start and End follow the paragraph's direction.
1256    fn line_offset(&self, line: &LaidLine, align: TextAlign) -> f32 {
1257        match (align, line.rtl) {
1258            (TextAlign::Center, _) => (self.width - line.width) * 0.5,
1259            (TextAlign::Start, false) | (TextAlign::End, true) => 0.0,
1260            (TextAlign::End, false) | (TextAlign::Start, true) => self.width - line.width,
1261        }
1262    }
1263
1264    #[allow(clippy::cast_precision_loss)]
1265    fn line_top(&self, index: usize) -> f32 {
1266        index as f32 * self.line_advance + self.half_leading
1267    }
1268
1269    /// Calls `place` with each glyph's drawing (cached in `drawings`), origin and pixels per
1270    /// font unit.
1271    #[allow(clippy::cast_precision_loss)]
1272    fn each_glyph(
1273        &self,
1274        faces: &Faces<'_>,
1275        request: &TextRasterRequest,
1276        drawings: &mut HashMap<(usize, u16), Drawing>,
1277        mut place: impl FnMut(&Drawing, [f32; 2], f32, &PositionedGlyph),
1278    ) {
1279        for (index, line) in self.lines.iter().enumerate() {
1280            let offset = self.line_offset(line, request.align);
1281            let baseline = self.line_top(index) + self.ascent;
1282            for glyph in &line.glyphs {
1283                let drawing = drawings
1284                    .entry((glyph.font, glyph.glyph_index))
1285                    .or_insert_with(|| Drawing::of(faces, glyph.font, glyph.glyph_index, request));
1286                let scale = request.font_size / faces.units_per_em(glyph.font);
1287                let origin = [glyph.x + glyph.x_offset + offset, baseline - glyph.y_offset];
1288                place(drawing, origin, scale, glyph);
1289            }
1290        }
1291    }
1292}
1293
1294fn include(bounds: &mut Option<[f32; 4]>, [left, top, right, bottom]: [f32; 4]) {
1295    let held = bounds.get_or_insert([left, top, right, bottom]);
1296    *held = [
1297        held[0].min(left),
1298        held[1].min(top),
1299        held[2].max(right),
1300        held[3].max(bottom),
1301    ];
1302}
1303
1304fn grown([left, top, right, bottom]: [f32; 4], by: f32) -> [f32; 4] {
1305    [left - by, top - by, right + by, bottom + by]
1306}
1307
1308/// Byte range (paragraph-relative during layout, then text-relative) and line of a cluster.
1309#[derive(Clone, Debug)]
1310struct ClusterSpan {
1311    bytes: Range<usize>,
1312    line: usize,
1313}
1314
1315struct LaidLine {
1316    /// Glyphs left to right, pens from the line's start.
1317    glyphs: Vec<PositionedGlyph>,
1318    /// Excludes hanging trailing spaces.
1319    width: f32,
1320    rtl: bool,
1321    /// Has glyphs, even if none with ink.
1322    has_glyphs: bool,
1323}
1324
1325/// Shapes, breaks at `max_width` (non-positive does not wrap) and visually orders one paragraph.
1326/// Clusters are numbered from `cluster_base` and lines from `line_base`; spans go to `spans`.
1327#[allow(clippy::too_many_arguments)]
1328fn layout_paragraph(
1329    faces: &Faces<'_>,
1330    paragraph: &str,
1331    font_size: f32,
1332    max_width: f32,
1333    order: &[usize],
1334    policy: EmojiPolicy,
1335    letter_spacing: f32,
1336    (cluster_base, line_base): (usize, usize),
1337    spans: &mut Vec<ClusterSpan>,
1338) -> Vec<LaidLine> {
1339    let bidi = ParagraphBidiInfo::new(paragraph, None);
1340    let rtl = bidi.paragraph_level.is_rtl();
1341    let glyphs = shape_paragraph(
1342        faces,
1343        paragraph,
1344        &bidi,
1345        font_size,
1346        order,
1347        policy,
1348        letter_spacing,
1349    );
1350    let clusters = clusters(&glyphs, paragraph, letter_spacing);
1351    let lines = break_lines(&clusters, max_width);
1352    for (line, range) in lines.iter().enumerate() {
1353        for index in range.clone() {
1354            let end = clusters
1355                .get(index + 1)
1356                .map_or(paragraph.len(), |next| next.start);
1357            spans.push(ClusterSpan {
1358                bytes: clusters[index].start..end.max(clusters[index].start),
1359                line: line_base + line,
1360            });
1361        }
1362    }
1363    lines
1364        .into_iter()
1365        .map(|range| {
1366            let has_glyphs = !range.is_empty();
1367            // Trailing spaces hang: no width, nothing drawn.
1368            let mut end = range.end;
1369            while end > range.start && clusters[end - 1].hangs {
1370                end -= 1;
1371            }
1372            let mut line = LaidLine {
1373                glyphs: Vec::new(),
1374                width: 0.0,
1375                rtl,
1376                has_glyphs,
1377            };
1378            if end == range.start {
1379                return line;
1380            }
1381            let bytes = clusters[range.start].start
1382                ..clusters
1383                    .get(range.end)
1384                    .map_or(paragraph.len(), |next| next.start);
1385            let levels = bidi.reordered_levels(bytes);
1386            // Letter spacing goes between visual neighbours, never after the line's last cluster.
1387            let mut cluster_of = vec![0; clusters[end - 1].glyphs.end];
1388            for (index, cluster) in clusters[range.start..end].iter().enumerate() {
1389                cluster_of[cluster.glyphs.clone()].fill(range.start + index);
1390            }
1391            let mut x = 0.0_f32;
1392            let mut previous: Option<usize> = None;
1393            for index in visual_order(
1394                clusters[range.start].glyphs.start..clusters[end - 1].glyphs.end,
1395                |index| levels[glyphs[index].cluster].number(),
1396            ) {
1397                let glyph = &glyphs[index];
1398                let cluster = cluster_of[index];
1399                if let Some(before) = previous.filter(|&before| before != cluster) {
1400                    x += clusters[before].spacing;
1401                }
1402                previous = Some(cluster);
1403                line.glyphs.push(PositionedGlyph {
1404                    font: glyph.font,
1405                    glyph_index: glyph.glyph_index,
1406                    cluster: cluster_base + cluster,
1407                    advance: glyph.advance,
1408                    x,
1409                    x_offset: glyph.x_offset,
1410                    y_offset: glyph.y_offset,
1411                });
1412                x += glyph.advance;
1413            }
1414            line.width = x;
1415            line
1416        })
1417        .collect()
1418}
1419
1420/// Reorders logical-order glyphs visually per UAX #9 rule L2.
1421fn visual_order(range: Range<usize>, level: impl Fn(usize) -> u8) -> Vec<usize> {
1422    let mut order = range.collect::<Vec<_>>();
1423    let levels = order.iter().map(|&index| level(index)).collect::<Vec<_>>();
1424    let highest = levels.iter().copied().max().unwrap_or(0);
1425    let Some(lowest_odd) = levels.iter().copied().filter(|level| level % 2 == 1).min() else {
1426        return order;
1427    };
1428    let mut current = levels;
1429    for at_least in (lowest_odd..=highest).rev() {
1430        let mut start = 0;
1431        while start < order.len() {
1432            if current[start] < at_least {
1433                start += 1;
1434                continue;
1435            }
1436            let end = (start..order.len())
1437                .find(|&index| current[index] < at_least)
1438                .unwrap_or(order.len());
1439            order[start..end].reverse();
1440            current[start..end].reverse();
1441            start = end;
1442        }
1443    }
1444    order
1445}
1446
1447/// A run with one direction, script and font.
1448#[derive(Clone, Debug, PartialEq)]
1449struct Run {
1450    range: Range<usize>,
1451    font: usize,
1452    script: Option<Script>,
1453    rtl: bool,
1454}
1455
1456/// Splits a paragraph into runs.
1457///
1458/// Each grapheme takes the first font in `order` that covers it. Common-only graphemes (spaces,
1459/// digits, punctuation) stay in the previous font when it covers them and take the neighbouring
1460/// script. Uncovered graphemes take their base character's font, else the previous font. Emoji
1461/// prefer color fonts under [`EmojiPolicy::Color`] and text fonts otherwise (as does U+FE0E).
1462fn itemize(
1463    faces: &Faces<'_>,
1464    paragraph: &str,
1465    bidi: &ParagraphBidiInfo<'_>,
1466    order: &[usize],
1467    policy: EmojiPolicy,
1468) -> Vec<Run> {
1469    let first = order.first().copied().unwrap_or(0);
1470    let (color, text): (Vec<usize>, Vec<usize>) =
1471        order.iter().partition(|&&font| faces.color[font]);
1472    let color_first = color.iter().chain(&text).copied().collect::<Vec<_>>();
1473    let text_first = text.iter().chain(&color).copied().collect::<Vec<_>>();
1474    let fits = |font: usize, color_wanted: bool| {
1475        faces.color[font] == color_wanted
1476            || if color_wanted {
1477                color.is_empty()
1478            } else {
1479                text.is_empty()
1480            }
1481    };
1482    let mut graphemes = Vec::<(usize, usize, Option<Script>, bool)>::new();
1483    let mut current = None;
1484    for (start, grapheme) in paragraph.grapheme_indices(true) {
1485        let script = grapheme
1486            .chars()
1487            .map(|character| character.script())
1488            .find(|script| !matches!(script, Script::Common | Script::Inherited | Script::Unknown));
1489        let wants_color = if grapheme.contains('\u{fe0e}') {
1490            Some(false)
1491        } else if is_emoji(grapheme) {
1492            Some(policy == EmojiPolicy::Color)
1493        } else {
1494            None
1495        };
1496        let order = match wants_color {
1497            Some(true) => color_first.as_slice(),
1498            Some(false) => text_first.as_slice(),
1499            None => order,
1500        };
1501        let stays = current.filter(|&font| {
1502            script.is_none()
1503                && wants_color.is_none_or(|wanted| fits(font, wanted))
1504                && faces.covers(font, grapheme)
1505        });
1506        let font = stays
1507            .or_else(|| {
1508                order
1509                    .iter()
1510                    .copied()
1511                    .find(|&font| faces.covers(font, grapheme))
1512            })
1513            .or_else(|| {
1514                let base = grapheme
1515                    .chars()
1516                    .find(|&character| !is_default_ignorable(character))?;
1517                order.iter().copied().find(|&font| faces.has(font, base))
1518            })
1519            .or(current)
1520            .unwrap_or(first);
1521        current = Some(font);
1522        graphemes.push((start, font, script, bidi.levels[start].is_rtl()));
1523    }
1524    let leading = graphemes.iter().find_map(|grapheme| grapheme.2);
1525    let mut before = leading;
1526    for grapheme in &mut graphemes {
1527        match grapheme.2 {
1528            Some(script) => before = Some(script),
1529            None => grapheme.2 = before,
1530        }
1531    }
1532    let mut runs = Vec::<Run>::new();
1533    for (index, &(start, font, script, rtl)) in graphemes.iter().enumerate() {
1534        let end = graphemes
1535            .get(index + 1)
1536            .map_or(paragraph.len(), |next| next.0);
1537        match runs.last_mut() {
1538            Some(run) if run.font == font && run.script == script && run.rtl == rtl => {
1539                run.range.end = end;
1540            }
1541            _ => runs.push(Run {
1542                range: start..end,
1543                font,
1544                script,
1545                rtl,
1546            }),
1547        }
1548    }
1549    runs
1550}
1551
1552fn shaping_script(script: Script) -> Option<rustybuzz::Script> {
1553    let code: [u8; 4] = script.short_name().as_bytes().try_into().ok()?;
1554    rustybuzz::Script::from_iso15924_tag(ttf_parser::Tag::from_bytes(&code))
1555}
1556
1557/// Shapes each run with surrounding text as context, returning glyphs in logical order.
1558fn shape_paragraph(
1559    faces: &Faces<'_>,
1560    paragraph: &str,
1561    bidi: &ParagraphBidiInfo<'_>,
1562    font_size: f32,
1563    order: &[usize],
1564    policy: EmojiPolicy,
1565    letter_spacing: f32,
1566) -> Vec<ShapedGlyph> {
1567    // CSS Text 3: letter spacing disables optional ligatures only.
1568    let features = if letter_spacing == 0.0 {
1569        Vec::new()
1570    } else {
1571        [b"liga", b"clig", b"dlig", b"hlig"]
1572            .map(|tag| rustybuzz::Feature::new(ttf_parser::Tag::from_bytes(tag), 0, ..))
1573            .to_vec()
1574    };
1575    let mut glyphs = Vec::new();
1576    for run in itemize(faces, paragraph, bidi, order, policy) {
1577        let face = faces.shaper(run.font);
1578        let units = faces.units_per_em(run.font);
1579        let mut buffer = UnicodeBuffer::new();
1580        buffer.push_str(&paragraph[run.range.clone()]);
1581        buffer.set_pre_context(&paragraph[..run.range.start]);
1582        buffer.set_post_context(&paragraph[run.range.end..]);
1583        buffer.set_direction(if run.rtl {
1584            Direction::RightToLeft
1585        } else {
1586            Direction::LeftToRight
1587        });
1588        match run.script.and_then(shaping_script) {
1589            Some(script) => buffer.set_script(script),
1590            None => buffer.guess_segment_properties(),
1591        }
1592        let shaped = rustybuzz::shape(face, &features, buffer);
1593        let first = glyphs.len();
1594        for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
1595            #[allow(clippy::cast_precision_loss)]
1596            glyphs.push(ShapedGlyph {
1597                font: run.font,
1598                // AAT `morx` substitutions may name a missing glyph; draw it as `.notdef`.
1599                glyph_index: u16::try_from(info.glyph_id)
1600                    .ok()
1601                    .filter(|&glyph| glyph < face.number_of_glyphs())
1602                    .unwrap_or(0),
1603                cluster: run.range.start + usize::try_from(info.cluster).unwrap_or(0),
1604                advance: position.x_advance as f32 * font_size / units,
1605                x_offset: position.x_offset as f32 * font_size / units,
1606                y_offset: position.y_offset as f32 * font_size / units,
1607            });
1608        }
1609        // RTL runs come back in visual order; line breaking needs logical order.
1610        if run.rtl {
1611            glyphs[first..].reverse();
1612        }
1613    }
1614    glyphs
1615}
1616
1617#[derive(Copy, Clone, Debug)]
1618struct ShapedGlyph {
1619    font: usize,
1620    glyph_index: u16,
1621    /// Byte offset in the paragraph.
1622    cluster: usize,
1623    advance: f32,
1624    /// Shaper offset from the pen position in pixels, y up.
1625    x_offset: f32,
1626    y_offset: f32,
1627}
1628
1629/// Glyphs a line never splits.
1630#[derive(Clone, Debug, PartialEq)]
1631struct Cluster {
1632    glyphs: Range<usize>,
1633    /// Byte offset in the paragraph.
1634    start: usize,
1635    /// Advance including letter spacing.
1636    width: f32,
1637    /// Letter spacing included in `width`.
1638    spacing: f32,
1639    /// Only hanging spaces.
1640    hangs: bool,
1641    /// UAX #14 allows a break before it.
1642    break_before: bool,
1643}
1644
1645/// Groups glyphs into clusters with UAX #14 break opportunities, tailored so Korean breaks
1646/// between words rather than between Hangul characters. Cursive clusters get no letter spacing.
1647fn clusters(glyphs: &[ShapedGlyph], paragraph: &str, letter_spacing: f32) -> Vec<Cluster> {
1648    let mut opportunities = unicode_linebreak::linebreaks(paragraph)
1649        .filter(|&(at, kind)| {
1650            kind == BreakOpportunity::Allowed
1651                && !(paragraph[..at].chars().next_back().is_some_and(is_hangul)
1652                    && paragraph[at..].chars().next().is_some_and(is_hangul))
1653        })
1654        .map(|(at, _)| at)
1655        .peekable();
1656    let mut clusters = Vec::<Cluster>::new();
1657    for (index, glyph) in glyphs.iter().enumerate() {
1658        match clusters.last_mut() {
1659            Some(cluster) if cluster.start == glyph.cluster => {
1660                cluster.glyphs.end = index + 1;
1661                cluster.width += glyph.advance;
1662            }
1663            _ => clusters.push(Cluster {
1664                glyphs: index..index + 1,
1665                start: glyph.cluster,
1666                width: glyph.advance,
1667                spacing: 0.0,
1668                hangs: false,
1669                break_before: false,
1670            }),
1671        }
1672    }
1673    for index in 0..clusters.len() {
1674        let start = clusters[index].start;
1675        let end = clusters
1676            .get(index + 1)
1677            .map(|next| next.start)
1678            .filter(|&end| end > start)
1679            .unwrap_or_else(|| {
1680                paragraph[start..]
1681                    .chars()
1682                    .next()
1683                    .map_or(start, |character| start + character.len_utf8())
1684            });
1685        clusters[index].hangs = paragraph[start..end].chars().all(hangs);
1686        if letter_spacing != 0.0 && !is_cursive(&paragraph[start..end]) {
1687            clusters[index].spacing = letter_spacing;
1688            clusters[index].width += letter_spacing;
1689        }
1690        let mut found = false;
1691        while let Some(&at) = opportunities.peek() {
1692            if at > start {
1693                break;
1694            }
1695            opportunities.next();
1696            found = true;
1697        }
1698        clusters[index].break_before = index > 0 && found;
1699    }
1700    clusters
1701}
1702
1703/// Breaks clusters into lines no wider than `max_width` (non-positive does not wrap) at the
1704/// last fitting opportunity, else between clusters. Hanging spaces never overflow a line.
1705fn break_lines(clusters: &[Cluster], max_width: f32) -> Vec<Range<usize>> {
1706    let mut lines = Vec::new();
1707    let mut start = 0;
1708    let mut x = 0.0_f32;
1709    // Latest break opportunity and the pen position there.
1710    let mut opportunity: Option<(usize, f32)> = None;
1711    for (index, cluster) in clusters.iter().enumerate() {
1712        if cluster.break_before && index > start {
1713            opportunity = Some((index, x));
1714        }
1715        let fits = |x: f32| x + cluster.width - cluster.spacing <= max_width;
1716        if max_width > 0.0 && !cluster.hangs && index > start && !fits(x) {
1717            let (end, width) = opportunity.unwrap_or((index, x));
1718            lines.push(start..end);
1719            start = end;
1720            x -= width;
1721            opportunity = None;
1722            // The carried-over text is still too wide, so break again before this cluster.
1723            if index > start && !fits(x) {
1724                lines.push(start..index);
1725                start = index;
1726                x = 0.0;
1727            }
1728        }
1729        x += cluster.width;
1730    }
1731    lines.push(start..clusters.len());
1732    lines
1733}
1734
1735#[derive(Copy, Clone, Debug)]
1736struct PositionedGlyph {
1737    font: usize,
1738    glyph_index: u16,
1739    /// Cluster index in logical order.
1740    cluster: usize,
1741    /// Excludes letter spacing.
1742    advance: f32,
1743    /// Pen position from the line's start.
1744    x: f32,
1745    /// Shaper offset from the pen position in pixels, y up.
1746    x_offset: f32,
1747    y_offset: f32,
1748}
1749
1750/// An outline filled with the text paint, or under [`EmojiPolicy::Color`] a color form.
1751enum Drawing {
1752    Outline(Outline),
1753    Painting(color::Painting),
1754    Bitmap(color::Bitmap),
1755}
1756
1757impl Drawing {
1758    /// COLR painting, else bitmap, else outline. The painting uses the text color opaque; its
1759    /// alpha applies to the whole glyph at composition.
1760    fn of(faces: &Faces<'_>, font: usize, glyph: u16, request: &TextRasterRequest) -> Self {
1761        let face = &faces.parsed[font];
1762        if request.emoji_policy == EmojiPolicy::Color && faces.color[font] {
1763            let [red, green, blue, _] = request.fill.foreground();
1764            if let Some(painting) = color::Painting::of(face, glyph, [red, green, blue, 255]) {
1765                return Self::Painting(painting);
1766            }
1767            if let Some(bitmap) = color::Bitmap::of(face, glyph, request.font_size) {
1768                return Self::Bitmap(bitmap);
1769            }
1770        }
1771        Self::Outline(Outline::of(face, glyph))
1772    }
1773
1774    /// Conservative `[x_min, y_min, x_max, y_max]` in font units.
1775    fn bounds(&self) -> Option<[f32; 4]> {
1776        match self {
1777            Self::Outline(outline) => outline.bounds,
1778            Self::Painting(painting) => painting.bounds(),
1779            Self::Bitmap(bitmap) => Some(bitmap.bounds()),
1780        }
1781    }
1782
1783    /// Tight `[x_min, y_min, x_max, y_max]` in font units of what is actually drawn.
1784    fn ink_bounds(&self) -> Option<[f32; 4]> {
1785        match self {
1786            Self::Outline(outline) => outline.curve_bounds(),
1787            Self::Painting(painting) => painting.bounds(),
1788            Self::Bitmap(bitmap) => bitmap.ink_bounds(),
1789        }
1790    }
1791
1792    /// Premultiplied RGBA8; empty for an outline.
1793    fn draw_color(&self, origin: [f32; 2], scale: f32, pixels: PixelBox) -> Vec<u8> {
1794        match self {
1795            Self::Outline(_) => Vec::new(),
1796            Self::Painting(painting) => painting.draw(origin, scale, pixels),
1797            Self::Bitmap(bitmap) => bitmap.draw(origin, scale, pixels),
1798        }
1799    }
1800}
1801
1802/// A glyph outline in font units, y up.
1803#[derive(Default)]
1804struct Outline {
1805    segments: Vec<Segment>,
1806    /// Bounds of all points, control points included.
1807    bounds: Option<[f32; 4]>,
1808}
1809
1810enum Segment {
1811    Line([f32; 2], [f32; 2]),
1812    Quad([f32; 2], [f32; 2], [f32; 2]),
1813    Cubic([f32; 2], [f32; 2], [f32; 2], [f32; 2]),
1814}
1815
1816/// Pixel box in the layout box, right and bottom exclusive.
1817#[derive(Copy, Clone, Debug)]
1818struct PixelBox {
1819    left: i64,
1820    top: i64,
1821    right: i64,
1822    bottom: i64,
1823}
1824
1825impl PixelBox {
1826    #[allow(clippy::cast_possible_truncation)]
1827    fn of(bounds: [f32; 4], origin: [f32; 2], scale: f32) -> Self {
1828        let [x_min, y_min, x_max, y_max] = bounds;
1829        // Same expression as `Outline::draw`, so every point lands inside.
1830        Self {
1831            left: (origin[0] + x_min * scale).floor() as i64,
1832            top: (origin[1] - y_max * scale).floor() as i64,
1833            right: (origin[0] + x_max * scale).ceil() as i64,
1834            bottom: (origin[1] - y_min * scale).ceil() as i64,
1835        }
1836    }
1837}
1838
1839#[cfg(test)]
1840thread_local! {
1841    static OUTLINES_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1842}
1843
1844impl Outline {
1845    fn of(face: &ttf_parser::Face<'_>, glyph: u16) -> Self {
1846        #[cfg(test)]
1847        OUTLINES_READ.with(|read| read.set(read.get() + 1));
1848        let mut sink = OutlineSink::default();
1849        face.outline_glyph(GlyphId(glyph), &mut sink);
1850        sink.close();
1851        sink.outline
1852    }
1853
1854    #[allow(
1855        clippy::cast_possible_truncation,
1856        clippy::cast_sign_loss,
1857        clippy::cast_precision_loss
1858    )]
1859    fn draw(&self, origin: [f32; 2], scale: f32, pixels: PixelBox) -> Vec<u8> {
1860        let width = usize::try_from(pixels.right - pixels.left).unwrap_or(0);
1861        let height = usize::try_from(pixels.bottom - pixels.top).unwrap_or(0);
1862        let (left, top) = (pixels.left as f32, pixels.top as f32);
1863        let raster = self.raster(width, height, |[x, y]| {
1864            point(origin[0] + x * scale - left, (origin[1] - y * scale) - top)
1865        });
1866        let mut alpha = vec![0_u8; width * height];
1867        raster.for_each_pixel(|index, coverage| {
1868            alpha[index] = (coverage.min(1.0) * 255.0).round() as u8;
1869        });
1870        alpha
1871    }
1872}
1873
1874impl Outline {
1875    /// Bounds of the curves themselves (endpoints and extrema), tighter than `bounds`.
1876    fn curve_bounds(&self) -> Option<[f32; 4]> {
1877        let mut bounds = None;
1878        let mut hold = |[x, y]: [f32; 2]| include(&mut bounds, [x, y, x, y]);
1879        for segment in &self.segments {
1880            match *segment {
1881                Segment::Line(from, to) => {
1882                    hold(from);
1883                    hold(to);
1884                }
1885                Segment::Quad(from, control, to) => {
1886                    hold(from);
1887                    hold(to);
1888                    for axis in 0..2 {
1889                        let curvature = from[axis] - 2.0 * control[axis] + to[axis];
1890                        let t = (from[axis] - control[axis]) / curvature;
1891                        if t > 0.0 && t < 1.0 {
1892                            hold(std::array::from_fn(|axis| {
1893                                let u = 1.0 - t;
1894                                u * u * from[axis] + 2.0 * u * t * control[axis] + t * t * to[axis]
1895                            }));
1896                        }
1897                    }
1898                }
1899                Segment::Cubic(from, first, second, to) => {
1900                    hold(from);
1901                    hold(to);
1902                    for axis in 0..2 {
1903                        let [p0, p1, p2, p3] = [from[axis], first[axis], second[axis], to[axis]];
1904                        for t in quadratic_roots(
1905                            -p0 + 3.0 * (p1 - p2) + p3,
1906                            2.0 * (p0 - 2.0 * p1 + p2),
1907                            p1 - p0,
1908                        )
1909                        .into_iter()
1910                        .flatten()
1911                        .filter(|t| *t > 0.0 && *t < 1.0)
1912                        {
1913                            hold(std::array::from_fn(|axis| {
1914                                let u = 1.0 - t;
1915                                u * u * u * from[axis]
1916                                    + 3.0 * u * u * t * first[axis]
1917                                    + 3.0 * u * t * t * second[axis]
1918                                    + t * t * t * to[axis]
1919                            }));
1920                        }
1921                    }
1922                }
1923            }
1924        }
1925        bounds
1926    }
1927
1928    fn raster(&self, width: usize, height: usize, at: impl Fn([f32; 2]) -> Point) -> Rasterizer {
1929        let mut raster = Rasterizer::new(width, height);
1930        for segment in &self.segments {
1931            match *segment {
1932                Segment::Line(from, to) => raster.draw_line(at(from), at(to)),
1933                Segment::Quad(from, control, to) => raster.draw_quad(at(from), at(control), at(to)),
1934                Segment::Cubic(from, first, second, to) => {
1935                    raster.draw_cubic(at(from), at(first), at(second), at(to));
1936                }
1937            }
1938        }
1939        raster
1940    }
1941}
1942
1943/// Real roots of `a t^2 + b t + c`; non-finite results count as none.
1944fn quadratic_roots(a: f32, b: f32, c: f32) -> [Option<f32>; 2] {
1945    let finite = |t: f32| t.is_finite().then_some(t);
1946    if a.abs() <= f32::EPSILON * (b.abs() + c.abs()) {
1947        return [finite(-c / b), None];
1948    }
1949    let discriminant = b * b - 4.0 * a * c;
1950    if discriminant < 0.0 {
1951        return [None, None];
1952    }
1953    let root = discriminant.sqrt();
1954    [
1955        finite((-b + root) / (2.0 * a)),
1956        finite((-b - root) / (2.0 * a)),
1957    ]
1958}
1959
1960#[derive(Default)]
1961struct OutlineSink {
1962    outline: Outline,
1963    start: [f32; 2],
1964    pen: [f32; 2],
1965}
1966
1967impl OutlineSink {
1968    fn include(&mut self, points: &[[f32; 2]]) {
1969        for &[x, y] in points {
1970            let bounds = self.outline.bounds.get_or_insert([x, y, x, y]);
1971            *bounds = [
1972                bounds[0].min(x),
1973                bounds[1].min(y),
1974                bounds[2].max(x),
1975                bounds[3].max(y),
1976            ];
1977        }
1978    }
1979}
1980
1981impl OutlineBuilder for OutlineSink {
1982    fn move_to(&mut self, x: f32, y: f32) {
1983        self.close();
1984        self.start = [x, y];
1985        self.pen = [x, y];
1986    }
1987
1988    fn line_to(&mut self, x: f32, y: f32) {
1989        let to = [x, y];
1990        self.include(&[self.pen, to]);
1991        self.outline.segments.push(Segment::Line(self.pen, to));
1992        self.pen = to;
1993    }
1994
1995    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
1996        let (control, to) = ([x1, y1], [x, y]);
1997        self.include(&[self.pen, control, to]);
1998        self.outline
1999            .segments
2000            .push(Segment::Quad(self.pen, control, to));
2001        self.pen = to;
2002    }
2003
2004    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
2005        let (first, second, to) = ([x1, y1], [x2, y2], [x, y]);
2006        self.include(&[self.pen, first, second, to]);
2007        self.outline
2008            .segments
2009            .push(Segment::Cubic(self.pen, first, second, to));
2010        self.pen = to;
2011    }
2012
2013    fn close(&mut self) {
2014        // A contour already back at its start needs no closing line.
2015        if self.pen.map(f32::to_bits) != self.start.map(f32::to_bits) {
2016            self.line_to(self.start[0], self.start[1]);
2017        }
2018    }
2019}
2020
2021fn validate_request(request: &TextRasterRequest) -> Result<(), TextRasterError> {
2022    if request.font_size.is_finite()
2023        && request.font_size > 0.0
2024        && request.font_size <= MAX_FONT_SIZE
2025        && request.text.len() <= MAX_TEXT_BYTES
2026        && request.max_width.is_finite()
2027        && request.max_width <= 16_384.0
2028        && request.line_height.is_finite()
2029        && request.line_height > 0.0
2030        && request.line_height <= MAX_LINE_HEIGHT
2031        && request.letter_spacing.is_finite()
2032        && request.letter_spacing >= LETTER_SPACING_RANGE.start() * request.font_size
2033        && request.letter_spacing <= LETTER_SPACING_RANGE.end() * request.font_size
2034        && (1..=1000).contains(&request.font_weight)
2035        && request.content_version > 0
2036        && request.fill.is_valid()
2037        && request.strokes.len() <= MAX_STROKES
2038        && request
2039            .strokes
2040            .iter()
2041            .all(|stroke| stroke.width.is_finite() && stroke.width > 0.0)
2042        && stroke_radius(&request.strokes).is_none_or(|radius| radius <= MAX_STROKE_EXTENT)
2043        && request.background.is_none_or(|background| {
2044            background.padding.is_finite()
2045                && background.padding >= 0.0
2046                && background.padding <= MAX_BACKGROUND_PADDING
2047        })
2048    {
2049        Ok(())
2050    } else {
2051        Err(TextRasterError::InvalidRequest)
2052    }
2053}
2054
2055fn checked_dimension(value: f32) -> Result<u32, TextRasterError> {
2056    if value.is_finite() && value > 0.0 && value.ceil() <= 16_384.0 {
2057        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2058        Ok(value.ceil() as u32)
2059    } else {
2060        Err(TextRasterError::TooLarge)
2061    }
2062}
2063
2064/// Max-combines a glyph's coverage into the mask so overlapping glyphs never erase each other.
2065fn place_glyph(
2066    mask: &mut [u8],
2067    width: u32,
2068    height: u32,
2069    origin_x: i64,
2070    origin_y: i64,
2071    glyph_width: usize,
2072    alpha: &[u8],
2073) {
2074    for (index, coverage) in alpha.iter().copied().enumerate() {
2075        let x = origin_x + i64::try_from(index % glyph_width.max(1)).unwrap_or(0);
2076        let y = origin_y + i64::try_from(index / glyph_width.max(1)).unwrap_or(0);
2077        if x < 0 || y < 0 || x >= i64::from(width) || y >= i64::from(height) {
2078            continue;
2079        }
2080        let pixel =
2081            u64::try_from(y).unwrap_or(0) * u64::from(width) + u64::try_from(x).unwrap_or(0);
2082        let slot = &mut mask[usize::try_from(pixel).unwrap_or(0)];
2083        *slot = (*slot).max(coverage);
2084    }
2085}
2086
2087/// Composites a color glyph over the color layer; later glyphs go on top.
2088fn place_color_glyph(
2089    colors: &mut [u8],
2090    width: u32,
2091    height: u32,
2092    origin_x: i64,
2093    origin_y: i64,
2094    glyph_width: usize,
2095    rgba8: &[u8],
2096) {
2097    for (index, source) in rgba8.as_chunks::<4>().0.iter().enumerate() {
2098        let x = origin_x + i64::try_from(index % glyph_width.max(1)).unwrap_or(0);
2099        let y = origin_y + i64::try_from(index / glyph_width.max(1)).unwrap_or(0);
2100        if x < 0 || y < 0 || x >= i64::from(width) || y >= i64::from(height) || source[3] == 0 {
2101            continue;
2102        }
2103        let pixel =
2104            u64::try_from(y).unwrap_or(0) * u64::from(width) + u64::try_from(x).unwrap_or(0);
2105        let slot = &mut colors[usize::try_from(pixel).unwrap_or(0) * 4..][..4];
2106        let blended = over(
2107            std::array::from_fn(|channel| u32::from(source[channel])),
2108            std::array::from_fn(|channel| u32::from(slot[channel])),
2109        );
2110        for (byte, value) in slot.iter_mut().zip(blended) {
2111            *byte = u8::try_from(value.min(255)).unwrap_or(u8::MAX);
2112        }
2113    }
2114}
2115
2116impl TextRasterRequest {
2117    /// Whole pixels the strokes or background reach past the layout box. The texture margin
2118    /// ([`TextTexture::layout_x`]) adds room for ink overflow to this.
2119    #[must_use]
2120    pub fn decoration_margin(&self) -> u32 {
2121        let (outline, padding) = decoration_extents(self);
2122        outline.max(padding)
2123    }
2124}
2125
2126/// Whole-pixel reach of the outermost stroke past the ink and the background past the box.
2127fn decoration_extents(request: &TextRasterRequest) -> (u32, u32) {
2128    let outline = stroke_radius(&request.strokes).map_or(0, disc_extent);
2129    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2130    let padding = request
2131        .background
2132        .map_or(0, |background| background.padding.ceil() as u32);
2133    (outline, padding)
2134}
2135
2136/// Sum of stroke widths, in order; `None` without strokes.
2137fn stroke_radius(strokes: &[TextStroke]) -> Option<f32> {
2138    let mut radius = None;
2139    for stroke in strokes {
2140        radius = Some(radius.unwrap_or(0.0) + stroke.width);
2141    }
2142    radius
2143}
2144
2145/// Integer radius of the disc a band of radius `width` dilates by.
2146#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2147fn disc_extent(width: f32) -> u32 {
2148    (width + 0.5).ceil() as u32
2149}
2150
2151/// Premultiplies `color` by `coverage`, truncating each product to a byte. Pinned rasters
2152/// depend on this truncation.
2153fn premultiplied(color: [u8; 4], coverage: u8) -> [u32; 4] {
2154    let alpha = u32::from(coverage) * u32::from(color[3]) / 255;
2155    [
2156        u32::from(color[0]) * alpha / 255,
2157        u32::from(color[1]) * alpha / 255,
2158        u32::from(color[2]) * alpha / 255,
2159        alpha,
2160    ]
2161}
2162
2163fn over(source: [u32; 4], destination: [u32; 4]) -> [u32; 4] {
2164    let keep = 255 - source[3];
2165    std::array::from_fn(|channel| source[channel] + (destination[channel] * keep + 127) / 255)
2166}
2167
2168trait PixelLook {
2169    /// Straight sRGB fill at `index`; `point` is the pixel center in the layout box.
2170    fn fill(&self, index: usize, point: [f32; 2]) -> [u8; 4];
2171    fn band(&self, stroke: usize, index: usize) -> [u8; 4];
2172    /// 8-bit opacity applied to the glyph layers together; `None` for opaque.
2173    fn fade(&self, _index: usize) -> Option<u32> {
2174        None
2175    }
2176}
2177
2178struct RequestLook<'a> {
2179    foreground: [u8; 4],
2180    gradient: Option<gradient::PreparedGradient<'a>>,
2181    strokes: &'a [TextStroke],
2182}
2183
2184impl<'a> RequestLook<'a> {
2185    fn of(request: &'a TextRasterRequest, layout_size: [f32; 2]) -> Self {
2186        Self {
2187            foreground: request.fill.foreground(),
2188            gradient: match &request.fill {
2189                TextFill::Solid(_) => None,
2190                TextFill::Gradient(gradient) => Some(gradient.prepared(layout_size)),
2191            },
2192            strokes: &request.strokes,
2193        }
2194    }
2195}
2196
2197impl PixelLook for RequestLook<'_> {
2198    #[inline]
2199    fn fill(&self, _: usize, point: [f32; 2]) -> [u8; 4] {
2200        self.gradient
2201            .as_ref()
2202            .map_or(self.foreground, |gradient| gradient.rgba8_at(point))
2203    }
2204
2205    #[inline]
2206    fn band(&self, stroke: usize, _: usize) -> [u8; 4] {
2207        self.strokes[stroke].color
2208    }
2209}
2210
2211/// Dilates coverage (including color glyph alpha) once per stroke, by the cumulative width.
2212fn stroke_bands(
2213    request: &TextRasterRequest,
2214    coverage: &[u8],
2215    colors: Option<&[u8]>,
2216    width: u32,
2217) -> Vec<Vec<u8>> {
2218    if request.strokes.is_empty() {
2219        return Vec::new();
2220    }
2221    let height = u32::try_from(coverage.len() / width.max(1) as usize).unwrap_or(0);
2222    let ink = colors.map(|colors| {
2223        coverage
2224            .iter()
2225            .zip(colors.as_chunks::<4>().0)
2226            .map(|(&text, color)| text.max(color[3]))
2227            .collect::<Vec<_>>()
2228    });
2229    let ink = ink.as_deref().unwrap_or(coverage);
2230    let mut radius = 0.0;
2231    request
2232        .strokes
2233        .iter()
2234        .map(|stroke| {
2235            radius += stroke.width;
2236            dilate(ink, width, height, radius)
2237        })
2238        .collect()
2239}
2240
2241/// Composes text over strokes over line boxes into premultiplied RGBA8, in integers except for
2242/// gradient colors so every target matches. `bands` are innermost first and drawn outermost
2243/// first. With a split background the boxes are returned as their own plane.
2244#[allow(
2245    clippy::cast_possible_truncation,
2246    clippy::cast_sign_loss,
2247    clippy::cast_precision_loss,
2248    clippy::too_many_arguments
2249)]
2250fn compose(
2251    request: &TextRasterRequest,
2252    coverage: &[u8],
2253    colors: Option<&[u8]>,
2254    width: u32,
2255    margin: [u32; 2],
2256    line_boxes: &[(f32, f32, f32)],
2257    bands: &[Vec<u8>],
2258    look: &impl PixelLook,
2259) -> (Vec<u8>, Option<Vec<u8>>) {
2260    let background = request.background.map(|background| {
2261        let [margin_x, margin_y] = margin.map(|margin| margin as f32);
2262        let padding = background.padding;
2263        let boxes = line_boxes
2264            .iter()
2265            .map(|&(left, line_width, top)| {
2266                (
2267                    margin_x + left - padding,
2268                    margin_x + left + line_width + padding,
2269                    margin_y + top - padding,
2270                    margin_y + top + request.font_size + padding,
2271                )
2272            })
2273            .collect::<Vec<_>>();
2274        (background.color, boxes)
2275    });
2276    let split = request.split_background && background.is_some();
2277    let mut split_plane = split.then(|| vec![0_u8; coverage.len() * 4]);
2278    let foreground = request.fill.foreground();
2279    let [margin_x, margin_y] = margin.map(|margin| margin as f32);
2280    let mut rgba8 = vec![0_u8; coverage.len() * 4];
2281    // Boxes are in line order, so the window `first..last` only moves down.
2282    let (mut first, mut last) = (0, 0);
2283    for (index, text) in coverage.iter().copied().enumerate() {
2284        let mut pixel = [0_u32; 4];
2285        let x = (index % width as usize) as f32;
2286        let y = (index / width as usize) as f32;
2287        if let Some((color, boxes)) = &background {
2288            if index % width as usize == 0 {
2289                while first < boxes.len() && boxes[first].3 <= y {
2290                    first += 1;
2291                }
2292                last = last.max(first);
2293                while last < boxes.len() && boxes[last].2 < y + 1.0 {
2294                    last += 1;
2295                }
2296            }
2297            let covered = boxes[first..last]
2298                .iter()
2299                .map(|&(left, right, top, bottom)| {
2300                    let across = (right.min(x + 1.0) - left.max(x)).clamp(0.0, 1.0);
2301                    let down = (bottom.min(y + 1.0) - top.max(y)).clamp(0.0, 1.0);
2302                    across * down
2303                })
2304                .fold(0.0_f32, f32::max);
2305            let boxed = premultiplied(*color, (covered * 255.0).round() as u8);
2306            match &mut split_plane {
2307                Some(plane) => {
2308                    for (channel, value) in boxed.iter().enumerate() {
2309                        plane[index * 4 + channel] = (*value).min(255) as u8;
2310                    }
2311                }
2312                None => pixel = boxed,
2313            }
2314        }
2315        // Fade a character's layers together so its strokes never show through its fill.
2316        let fade = look.fade(index);
2317        let boxes = pixel;
2318        if fade.is_some() {
2319            pixel = [0; 4];
2320        }
2321        for (stroke, band) in bands.iter().enumerate().rev() {
2322            if band[index] > 0 {
2323                pixel = over(premultiplied(look.band(stroke, index), band[index]), pixel);
2324            }
2325        }
2326        if text > 0 {
2327            let fill = look.fill(index, [x + 0.5 - margin_x, y + 0.5 - margin_y]);
2328            pixel = over(premultiplied(fill, text), pixel);
2329        }
2330        if let Some(colors) = colors {
2331            let color = &colors[index * 4..][..4];
2332            if color[3] > 0 {
2333                let opacity = u32::from(foreground[3]);
2334                pixel = over(
2335                    std::array::from_fn(|channel| u32::from(color[channel]) * opacity / 255),
2336                    pixel,
2337                );
2338            }
2339        }
2340        if let Some(fade) = fade {
2341            pixel = over(pixel.map(|value| (value * fade + 127) / 255), boxes);
2342        }
2343        for (channel, value) in pixel.iter().enumerate() {
2344            rgba8[index * 4 + channel] = (*value).min(255) as u8;
2345        }
2346    }
2347    (rgba8, split_plane)
2348}
2349
2350/// Dilates coverage by a disc of radius `width` (max over the disc, rim pixels weighted).
2351///
2352/// Only the ink bounds grown by the disc are visited. Each disc row's fully weighted taps form one
2353/// run, computed as a van Herk/Gil-Werman sliding maximum; only the rim is visited tap by tap. The
2354/// result is byte-identical to a tap-by-tap pass.
2355#[allow(
2356    clippy::cast_possible_truncation,
2357    clippy::cast_sign_loss,
2358    clippy::cast_precision_loss,
2359    clippy::cast_possible_wrap
2360)]
2361fn dilate(coverage: &[u8], width: u32, height: u32, radius: f32) -> Vec<u8> {
2362    let (width, height) = (width as usize, height as usize);
2363    let mut band = vec![0_u8; coverage.len()];
2364    let Some(ink) = ink_bounds(coverage, width, height) else {
2365        return band;
2366    };
2367    let disc = Disc::new(radius);
2368    let extent = disc.extent;
2369    let reach = Bounds {
2370        left: ink.left.saturating_sub(extent),
2371        top: ink.top.saturating_sub(extent),
2372        right: (ink.right + extent).min(width),
2373        bottom: (ink.bottom + extent).min(height),
2374    };
2375    // Compute each run half-width once per source row and apply it to every output row using it.
2376    let span = reach.right - reach.left;
2377    let mut window = WindowMax::default();
2378    let mut maxima = vec![0_u8; span];
2379    for (half, offsets) in &disc.runs {
2380        for source_y in ink.top..ink.bottom {
2381            let row = &coverage[source_y * width..(source_y + 1) * width];
2382            window.of(row, reach.left, span, *half, &mut maxima);
2383            for &offset in offsets {
2384                let Some(y) = source_y.checked_add_signed(-offset) else {
2385                    continue;
2386                };
2387                if y < reach.top || y >= reach.bottom {
2388                    continue;
2389                }
2390                let out = &mut band[y * width + reach.left..y * width + reach.right];
2391                for (pixel, highest) in out.iter_mut().zip(&maxima) {
2392                    *pixel = (*pixel).max(*highest);
2393                }
2394            }
2395        }
2396    }
2397    for &(dx, dy, weight) in &disc.rim {
2398        for source_y in ink.top..ink.bottom {
2399            let Some(y) = source_y.checked_add_signed(-dy) else {
2400                continue;
2401            };
2402            if y >= height {
2403                continue;
2404            }
2405            // Column x reads x + dx; keep both in the texture and the source in the ink.
2406            let first = ink.left.max(usize::try_from(dx).unwrap_or(0));
2407            let last = ink
2408                .right
2409                .min(usize::try_from(width as isize + dx.min(0)).unwrap_or(0));
2410            if first >= last {
2411                continue;
2412            }
2413            let source = &coverage[source_y * width + first..source_y * width + last];
2414            let start = y * width + (first as isize - dx) as usize;
2415            let out = &mut band[start..start + source.len()];
2416            for (pixel, value) in out.iter_mut().zip(source) {
2417                *pixel = (*pixel).max((u32::from(*value) * weight / 255) as u8);
2418            }
2419        }
2420    }
2421    band
2422}
2423
2424/// A rectangle of pixels, right and bottom exclusive.
2425#[derive(Copy, Clone, Debug, Eq, PartialEq)]
2426struct Bounds {
2427    left: usize,
2428    top: usize,
2429    right: usize,
2430    bottom: usize,
2431}
2432
2433fn ink_bounds(coverage: &[u8], width: usize, height: usize) -> Option<Bounds> {
2434    let mut bounds: Option<Bounds> = None;
2435    for y in 0..height {
2436        let row = &coverage[y * width..(y + 1) * width];
2437        let Some(left) = row.iter().position(|&value| value > 0) else {
2438            continue;
2439        };
2440        let right = row.iter().rposition(|&value| value > 0).unwrap_or(left) + 1;
2441        bounds = Some(bounds.map_or(
2442            Bounds {
2443                left,
2444                top: y,
2445                right,
2446                bottom: y + 1,
2447            },
2448            |bounds| Bounds {
2449                left: bounds.left.min(left),
2450                top: bounds.top,
2451                right: bounds.right.max(right),
2452                bottom: y + 1,
2453            },
2454        ));
2455    }
2456    bounds
2457}
2458
2459/// Disc taps split into full-weight row runs and partially weighted rim taps.
2460struct Disc {
2461    extent: usize,
2462    /// Run half-width and the row offsets whose full taps are exactly `-half..=half`.
2463    runs: Vec<(usize, Vec<isize>)>,
2464    /// `(dx, dy, weight)` for taps weighted strictly between 0 and 255.
2465    rim: Vec<(isize, isize, u32)>,
2466}
2467
2468impl Disc {
2469    /// A tap's weight is its area within `radius + 0.5` of the center, in 255ths.
2470    #[allow(
2471        clippy::cast_possible_truncation,
2472        clippy::cast_sign_loss,
2473        clippy::cast_precision_loss,
2474        clippy::cast_possible_wrap
2475    )]
2476    fn new(radius: f32) -> Self {
2477        let extent = disc_extent(radius) as isize;
2478        let mut runs: Vec<(usize, Vec<isize>)> = Vec::new();
2479        let mut rim = Vec::new();
2480        for dy in -extent..=extent {
2481            let mut half = None;
2482            for dx in -extent..=extent {
2483                let distance = ((dx * dx + dy * dy) as f32).sqrt();
2484                let weight = ((radius + 0.5 - distance).clamp(0.0, 1.0) * 255.0).round() as u32;
2485                match weight {
2486                    0 => {}
2487                    // Weight falls with distance, so a row's full taps form one centered run.
2488                    255 => half = Some(dx.unsigned_abs()),
2489                    _ => rim.push((dx, dy, weight)),
2490                }
2491            }
2492            if let Some(half) = half {
2493                match runs.iter_mut().find(|(run, _)| *run == half) {
2494                    Some((_, offsets)) => offsets.push(dy),
2495                    None => runs.push((half, vec![dy])),
2496                }
2497            }
2498        }
2499        Self {
2500            extent: extent as usize,
2501            runs,
2502            rim,
2503        }
2504    }
2505}
2506
2507/// Scratch for van Herk/Gil-Werman sliding-window maxima.
2508#[derive(Default)]
2509struct WindowMax {
2510    padded: Vec<u8>,
2511    from_start: Vec<u8>,
2512    to_end: Vec<u8>,
2513}
2514
2515impl WindowMax {
2516    /// `out[i]` = max of `row` over `left + i - half ..= left + i + half`, 0 outside the row.
2517    fn of(&mut self, row: &[u8], left: usize, count: usize, half: usize, out: &mut [u8]) {
2518        let window = 2 * half + 1;
2519        let length = count + 2 * half;
2520        self.padded.clear();
2521        self.padded.resize(length, 0);
2522        // Padded index i is column left - half + i.
2523        let first_column = left.saturating_sub(half);
2524        let last_column = (left + count + half).min(row.len());
2525        if first_column < last_column {
2526            let at = first_column + half - left;
2527            self.padded[at..at + last_column - first_column]
2528                .copy_from_slice(&row[first_column..last_column]);
2529        }
2530        self.from_start.clear();
2531        self.from_start.resize(length, 0);
2532        self.to_end.clear();
2533        self.to_end.resize(length, 0);
2534        for block in (0..length).step_by(window) {
2535            let end = (block + window).min(length);
2536            let mut highest = 0;
2537            for index in block..end {
2538                highest = highest.max(self.padded[index]);
2539                self.from_start[index] = highest;
2540            }
2541            highest = 0;
2542            for index in (block..end).rev() {
2543                highest = highest.max(self.padded[index]);
2544                self.to_end[index] = highest;
2545            }
2546        }
2547        for (index, value) in out[..count].iter_mut().enumerate() {
2548            *value = self.to_end[index].max(self.from_start[index + window - 1]);
2549        }
2550    }
2551}
2552
2553#[cfg(test)]
2554mod tests;