Skip to main content

alpha2_text/
lib.rs

1//! Text shaping and rasterization for alpha2 hosts.
2//!
3//! The engine takes text as a host texture ([`TextTexture`]); this crate is
4//! one host for it, in pure Rust, from font bytes the caller supplies. Each
5//! paragraph is split by direction (UAX #9), script, and font, rustybuzz
6//! shapes each run, lines break where UAX #14 allows, and each glyph's
7//! outline is drawn by `ab_glyph_rasterizer` when it is placed. It builds
8//! the same way natively and for wasm, and the same request with the same
9//! fonts gives the same pixels on every target, so a browser preview and a
10//! native export of one project draw identical text. `alpha2/text` exposes
11//! it to JavaScript as `WasmTextRasterizer`.
12
13use std::cell::OnceCell;
14use std::cmp::Ordering;
15use std::collections::{BTreeMap, HashMap};
16use std::fmt;
17use std::ops::Range;
18use std::sync::Arc;
19
20use ab_glyph_rasterizer::{Rasterizer, point};
21use alpha2_media::{
22    EmojiPolicy, HostTexture, HostTexturePixels, Premultiplication, RasterColorSpace, TextAlign,
23    TextTexture,
24};
25use rustybuzz::ttf_parser::{self, GlyphId, OutlineBuilder};
26use rustybuzz::{Direction, UnicodeBuffer};
27use unicode_bidi::ParagraphBidiInfo;
28use unicode_linebreak::BreakOpportunity;
29use unicode_script::{Script, UnicodeScript};
30use unicode_segmentation::UnicodeSegmentation;
31
32mod unicode_tables;
33
34const MAX_TEXT_BYTES: usize = 64 * 1024;
35const MAX_FONT_SIZE: f32 = 2_048.0;
36const MAX_LINE_HEIGHT: f32 = 10.0;
37const MAX_OUTLINE_WIDTH: f32 = 64.0;
38const MAX_BACKGROUND_PADDING: f32 = 256.0;
39
40/// One caller-owned font registered in fallback order.
41#[derive(Clone, Debug)]
42pub struct TextFont {
43    /// Stable family name used in cache keys and diagnostics.
44    pub family: String,
45    /// OpenType font bytes.
46    pub bytes: Arc<[u8]>,
47    /// Face index for font collections.
48    pub face_index: u32,
49}
50
51/// One text layout request.
52#[derive(Clone, Debug, PartialEq)]
53pub struct TextRasterRequest {
54    /// UTF-8 text shaped by the selected font fallback stack.
55    pub text: String,
56    /// Logical font size in pixels.
57    pub font_size: f32,
58    /// Maximum layout width. Non-positive values disable wrapping.
59    pub max_width: f32,
60    /// Baseline-to-baseline distance in font-size multiples.
61    pub line_height: f32,
62    /// Per-line horizontal alignment.
63    pub align: TextAlign,
64    /// Straight (not premultiplied), sRGB-encoded RGBA color. The output is
65    /// premultiplied by glyph coverage and this alpha.
66    pub color: [u8; 4],
67    /// Explicit handling for color-only emoji.
68    pub emoji_policy: EmojiPolicy,
69    /// Caller-managed version. Change it whenever content/style/font inputs change.
70    pub content_version: u64,
71    /// A band around the glyphs, drawn behind them.
72    pub outline: Option<TextOutline>,
73    /// A box behind each line, drawn behind the outline.
74    pub background: Option<TextBackground>,
75    /// The family drawn first, as [`font_family`] reads it, matched without
76    /// regard to ASCII case; the rest of the stack fills in what it lacks.
77    /// `None`, or a family no font has, keeps the fallback order.
78    pub font_family: Option<String>,
79}
80
81/// A band of `width` pixels around every glyph, behind it, in a straight
82/// (not premultiplied), sRGB-encoded RGBA color.
83///
84/// The band is the glyph coverage dilated by a disc: each pixel takes the
85/// highest coverage within `width` of it, weighted down over the disc's last
86/// pixel so the band's edge is antialiased.
87#[derive(Copy, Clone, Debug, PartialEq)]
88pub struct TextOutline {
89    /// Straight, sRGB-encoded RGBA.
90    pub color: [u8; 4],
91    /// Radius of the band in pixels, in `(0, 64]`.
92    pub width: f32,
93}
94
95/// A box behind each non-empty line: the line's ink width and one font size
96/// tall, grown by `padding` pixels on every side. Boxes that meet merge.
97#[derive(Copy, Clone, Debug, PartialEq)]
98pub struct TextBackground {
99    /// Straight, sRGB-encoded RGBA.
100    pub color: [u8; 4],
101    /// Space between the text and the box edge, in `[0, 256]` pixels.
102    pub padding: f32,
103}
104
105/// Rasterized pixels and their text-specific layout descriptor.
106#[derive(Clone, Debug, PartialEq)]
107pub struct TextRasterResult {
108    /// Layout facts sharing the generic compositor texture contract.
109    pub descriptor: TextTexture,
110    /// Tightly packed premultiplied RGBA8 pixels.
111    pub pixels: HostTexturePixels,
112}
113
114/// Typed text-host failure.
115#[derive(Clone, Debug, Eq, PartialEq)]
116#[non_exhaustive]
117pub enum TextRasterError {
118    /// No usable fonts were registered.
119    NoFonts,
120    /// OpenType parsing failed.
121    InvalidFont(String),
122    /// Request values are non-finite or outside the supported range.
123    InvalidRequest,
124    /// Computed dimensions cannot be represented safely.
125    TooLarge,
126}
127
128impl fmt::Display for TextRasterError {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            Self::NoFonts => formatter.write_str("text rasterizer requires at least one font"),
132            Self::InvalidFont(error) => write!(formatter, "invalid text font: {error}"),
133            Self::InvalidRequest => formatter.write_str("invalid text raster request"),
134            Self::TooLarge => formatter.write_str("text raster result is too large"),
135        }
136    }
137}
138
139impl std::error::Error for TextRasterError {}
140
141#[derive(Clone, Debug)]
142struct LoadedFont {
143    source: TextFont,
144    /// The family its name table gives, if any.
145    family: Option<String>,
146}
147
148impl LoadedFont {
149    /// Checks that the bytes hold the face and reads its family. Nothing
150    /// else is read until a request draws with it: a glyph's outline when
151    /// the glyph is placed.
152    fn parse(source: TextFont) -> Result<Self, TextRasterError> {
153        check_font(&source.bytes, source.face_index)?;
154        let family = font_family(&source.bytes, source.face_index);
155        Ok(Self { source, family })
156    }
157}
158
159/// Checks that `bytes` hold an OpenType face at `face_index`, as adding the
160/// font to a rasterizer checks it, without keeping anything: a host can
161/// refuse a file at import that the rasterizer would refuse later.
162///
163/// # Errors
164/// Returns [`TextRasterError::InvalidFont`] with the parser's reason (a WOFF
165/// or WOFF2 container's unknown magic, say).
166pub fn check_font(bytes: &[u8], face_index: u32) -> Result<(), TextRasterError> {
167    ttf_parser::Face::parse(bytes, face_index)
168        .map(|_| ())
169        .map_err(|error| TextRasterError::InvalidFont(error.to_string()))
170}
171
172/// The family a font's name table gives the face at `face_index`: its
173/// typographic family (name ID 16), else its family (name ID 1), in US
174/// English where the table has it, else in the first entry that decodes.
175/// `None` for bytes that do not parse or name no family.
176#[must_use]
177pub fn font_family(bytes: &[u8], face_index: u32) -> Option<String> {
178    use rustybuzz::ttf_parser::{Language, name_id};
179    let face = rustybuzz::ttf_parser::Face::parse(bytes, face_index).ok()?;
180    let names = face.names();
181    [name_id::TYPOGRAPHIC_FAMILY, name_id::FAMILY]
182        .into_iter()
183        .find_map(|id| {
184            let decoded = || {
185                names
186                    .into_iter()
187                    .filter(move |name| name.name_id == id)
188                    .filter_map(|name| {
189                        let text = name.to_string()?;
190                        (!text.trim().is_empty()).then_some((name.language(), text))
191                    })
192            };
193            decoded()
194                .find(|(language, _)| *language == Language::English_UnitedStates)
195                .or_else(|| decoded().next())
196                .map(|(_, text)| text)
197        })
198}
199
200/// The pixel bytes a [`TextRasterizer`] keeps cached by default: room for a
201/// few full-frame 4K titles, or hundreds of caption-sized ones.
202pub const DEFAULT_TEXT_CACHE_BYTES: usize = 64 << 20;
203
204/// What a cached result costs beyond its pixels, so that results with few or
205/// no pixels still count toward the budget and their number stays bounded.
206const CACHE_ENTRY_OVERHEAD: usize = 256;
207
208/// Deterministic text host with version-key result caching.
209///
210/// Results are cached by content version, least recently used first out,
211/// within a byte budget ([`DEFAULT_TEXT_CACHE_BYTES`] unless
212/// [`Self::set_cache_budget`] changes it). A cached result is shared, not
213/// copied: a request whose version is cached returns the same [`Arc`].
214#[derive(Debug)]
215pub struct TextRasterizer {
216    fonts: Vec<LoadedFont>,
217    cache: ResultCache,
218}
219
220/// Rasterized results by content version, within a byte budget, evicting the
221/// least recently used.
222#[derive(Debug)]
223struct ResultCache {
224    entries: HashMap<u64, CacheEntry>,
225    /// Content versions by last use, oldest first.
226    by_use: BTreeMap<u64, u64>,
227    next_use: u64,
228    bytes: usize,
229    budget: usize,
230}
231
232#[derive(Debug)]
233struct CacheEntry {
234    result: Arc<TextRasterResult>,
235    last_use: u64,
236    bytes: usize,
237}
238
239impl ResultCache {
240    fn new(budget: usize) -> Self {
241        Self {
242            entries: HashMap::new(),
243            by_use: BTreeMap::new(),
244            next_use: 0,
245            bytes: 0,
246            budget,
247        }
248    }
249
250    fn get(&mut self, version: u64) -> Option<Arc<TextRasterResult>> {
251        let entry = self.entries.get_mut(&version)?;
252        self.by_use.remove(&entry.last_use);
253        entry.last_use = self.next_use;
254        self.by_use.insert(self.next_use, version);
255        self.next_use += 1;
256        Some(Arc::clone(&entry.result))
257    }
258
259    /// Keeps `result` under `version` if it fits the budget, evicting the
260    /// least recently used results to make room.
261    fn insert(&mut self, version: u64, result: &Arc<TextRasterResult>) {
262        self.remove(version);
263        let bytes = result
264            .pixels
265            .rgba8
266            .len()
267            .saturating_add(CACHE_ENTRY_OVERHEAD);
268        if bytes > self.budget {
269            return;
270        }
271        self.bytes += bytes;
272        self.entries.insert(
273            version,
274            CacheEntry {
275                result: Arc::clone(result),
276                last_use: self.next_use,
277                bytes,
278            },
279        );
280        self.by_use.insert(self.next_use, version);
281        self.next_use += 1;
282        self.evict();
283    }
284
285    fn remove(&mut self, version: u64) {
286        if let Some(entry) = self.entries.remove(&version) {
287            self.by_use.remove(&entry.last_use);
288            self.bytes -= entry.bytes;
289        }
290    }
291
292    fn clear(&mut self) {
293        self.entries.clear();
294        self.by_use.clear();
295        self.bytes = 0;
296    }
297
298    fn set_budget(&mut self, budget: usize) {
299        self.budget = budget;
300        self.evict();
301    }
302
303    fn evict(&mut self) {
304        while self.bytes > self.budget {
305            let Some((_, version)) = self.by_use.pop_first() else {
306                break;
307            };
308            if let Some(entry) = self.entries.remove(&version) {
309                self.bytes -= entry.bytes;
310            }
311        }
312    }
313}
314
315impl TextRasterizer {
316    /// A rasterizer with no fonts yet; add them with [`Self::add_font`].
317    #[must_use]
318    pub fn empty() -> Self {
319        Self {
320            fonts: Vec::new(),
321            cache: ResultCache::new(DEFAULT_TEXT_CACHE_BYTES),
322        }
323    }
324
325    /// Takes a fallback-ordered OpenType stack.
326    ///
327    /// # Errors
328    /// Returns [`TextRasterError::NoFonts`] or the first malformed font.
329    pub fn new(fonts: Vec<TextFont>) -> Result<Self, TextRasterError> {
330        if fonts.is_empty() {
331            return Err(TextRasterError::NoFonts);
332        }
333        let loaded = fonts
334            .into_iter()
335            .map(LoadedFont::parse)
336            .collect::<Result<Vec<_>, _>>()?;
337        Ok(Self {
338            fonts: loaded,
339            cache: ResultCache::new(DEFAULT_TEXT_CACHE_BYTES),
340        })
341    }
342
343    /// Appends a font to the end of the fallback stack.
344    ///
345    /// Fonts change what every request draws, so the result cache is
346    /// cleared.
347    ///
348    /// # Errors
349    /// Returns [`TextRasterError::InvalidFont`] for malformed font bytes.
350    pub fn add_font(&mut self, source: TextFont) -> Result<(), TextRasterError> {
351        self.fonts.push(LoadedFont::parse(source)?);
352        self.cache.clear();
353        Ok(())
354    }
355
356    /// Number of fonts in the fallback stack.
357    #[must_use]
358    pub fn font_count(&self) -> usize {
359        self.fonts.len()
360    }
361
362    /// Shapes, lays out, and rasterizes one request, or returns the cached
363    /// result of its exact version, shared rather than copied.
364    ///
365    /// # Errors
366    /// Returns a typed validation, font, or size error.
367    pub fn rasterize(
368        &mut self,
369        request: &TextRasterRequest,
370    ) -> Result<Arc<TextRasterResult>, TextRasterError> {
371        validate_request(request)?;
372        if self.fonts.is_empty() {
373            return Err(TextRasterError::NoFonts);
374        }
375        if let Some(cached) = self.cache.get(request.content_version) {
376            return Ok(cached);
377        }
378        let result = Arc::new(self.rasterize_uncached(request)?);
379        self.cache.insert(request.content_version, &result);
380        Ok(result)
381    }
382
383    /// Drops a single host version after content or font invalidation.
384    pub fn invalidate(&mut self, content_version: u64) {
385        self.cache.remove(content_version);
386    }
387
388    /// Sets how many bytes of results the cache keeps, evicting the least
389    /// recently used down to it. Each result counts its pixels and a small
390    /// fixed overhead; a result larger than the budget is returned but not
391    /// kept. Zero keeps nothing.
392    pub fn set_cache_budget(&mut self, bytes: usize) {
393        self.cache.set_budget(bytes);
394    }
395
396    /// Bytes the cached results count against the budget.
397    #[must_use]
398    pub fn cache_bytes(&self) -> usize {
399        self.cache.bytes
400    }
401
402    /// Drops every rasterized result while retaining parsed fonts.
403    pub fn clear(&mut self) {
404        self.cache.clear();
405    }
406
407    /// Number of versioned raster results currently retained.
408    #[must_use]
409    pub fn cache_len(&self) -> usize {
410        self.cache.entries.len()
411    }
412
413    #[allow(
414        clippy::cast_possible_truncation,
415        clippy::cast_precision_loss,
416        clippy::too_many_lines
417    )]
418    fn rasterize_uncached(
419        &self,
420        request: &TextRasterRequest,
421    ) -> Result<TextRasterResult, TextRasterError> {
422        let line_advance = request.font_size * request.line_height;
423        let ascent = request.font_size * 0.8;
424        let descent = request.font_size * 0.2;
425        let faces = Faces::new(&self.fonts)?;
426        let order = self.font_order(request.font_family.as_deref());
427        let mut lines = Vec::new();
428        // Lines up to the last with glyphs stay, however little ink they have; blank lines after it
429        // take no room.
430        let mut kept = 1;
431        for paragraph in normalize(&request.text, request.emoji_policy).split('\n') {
432            for line in layout_paragraph(
433                &faces,
434                paragraph,
435                request.font_size,
436                request.max_width,
437                &order,
438            ) {
439                lines.push(line);
440                if lines.last().is_some_and(|line| line.has_glyphs) {
441                    kept = lines.len();
442                }
443            }
444        }
445        lines.truncate(kept);
446        let line_count = u32::try_from(lines.len()).map_err(|_| TextRasterError::TooLarge)?;
447        let layout_width = if request.max_width > 0.0 {
448            request.max_width
449        } else {
450            lines.iter().map(|line| line.width).fold(0.0, f32::max)
451        }
452        .max(1.0);
453        // Every line's nominal em box fits, the last one's too when lines are closer than an em.
454        let layout_height =
455            ((line_count - 1) as f32 * line_advance + line_advance.max(request.font_size)).max(1.0);
456        let box_width = checked_dimension(layout_width)?;
457        let box_height = checked_dimension(layout_height)?;
458        // Where each line starts in the box: Start and End are the paragraph's own sides.
459        let line_offset = |line: &LaidLine| match (request.align, line.rtl) {
460            (TextAlign::Center, _) => (layout_width - line.width) * 0.5,
461            (TextAlign::Start, false) | (TextAlign::End, true) => 0.0,
462            (TextAlign::End, false) | (TextAlign::Start, true) => layout_width - line.width,
463        };
464        let mut outlines = HashMap::<(usize, u16), Outline>::new();
465        let mut placed = Vec::new();
466        // Ink past the box on each axis, and the ink's own reach above and below its baselines.
467        let (mut overflow_x, mut overflow_y) = (0_i64, 0_i64);
468        let (mut ink_ascent, mut ink_descent) = (ascent, descent);
469        for (index, line) in lines.iter().enumerate() {
470            let offset = line_offset(line);
471            let baseline = index as f32 * line_advance + ascent;
472            for glyph in &line.glyphs {
473                let outline = outlines
474                    .entry((glyph.font, glyph.glyph_index))
475                    .or_insert_with(|| Outline::of(&faces.parsed[glyph.font], glyph.glyph_index));
476                let Some(bounds) = outline.bounds else {
477                    continue;
478                };
479                let scale = request.font_size / faces.units_per_em(glyph.font);
480                let origin = [glyph.x + glyph.x_offset + offset, baseline - glyph.y_offset];
481                let pixels = PixelBox::of(bounds, origin, scale);
482                overflow_x = overflow_x
483                    .max(pixels.left.saturating_neg())
484                    .max(pixels.right.saturating_sub(i64::from(box_width)));
485                overflow_y = overflow_y
486                    .max(pixels.top.saturating_neg())
487                    .max(pixels.bottom.saturating_sub(i64::from(box_height)));
488                ink_ascent = ink_ascent.max(bounds[3] * scale + glyph.y_offset);
489                ink_descent = ink_descent.max(-(bounds[1] * scale + glyph.y_offset));
490                placed.push(((glyph.font, glyph.glyph_index), origin, scale, pixels));
491            }
492        }
493        // The texture is the box plus a margin, equal on both ends of an axis so the box stays
494        // centred, with room for decorations and for outlined ink past the box.
495        let (outline, padding) = decoration_extents(request);
496        let margin = |overflow: i64| -> Result<u32, TextRasterError> {
497            u32::try_from(overflow)
498                .map_err(|_| TextRasterError::TooLarge)?
499                .checked_add(outline)
500                .map(|margin| margin.max(outline.max(padding)))
501                .ok_or(TextRasterError::TooLarge)
502        };
503        let (margin_x, margin_y) = (margin(overflow_x)?, margin(overflow_y)?);
504        let width = margin_x
505            .checked_mul(2)
506            .and_then(|margins| box_width.checked_add(margins))
507            .ok_or(TextRasterError::TooLarge)?;
508        let height = margin_y
509            .checked_mul(2)
510            .and_then(|margins| box_height.checked_add(margins))
511            .ok_or(TextRasterError::TooLarge)?;
512        if width > 16_384 || height > 16_384 {
513            return Err(TextRasterError::TooLarge);
514        }
515        let pixel_count = usize::try_from(width)
516            .ok()
517            .and_then(|width| {
518                usize::try_from(height)
519                    .ok()
520                    .and_then(|height| width.checked_mul(height))
521            })
522            .ok_or(TextRasterError::TooLarge)?;
523        let mut coverage = vec![0_u8; pixel_count];
524        let mut line_boxes = Vec::with_capacity(lines.len());
525        for (index, line) in lines.iter().enumerate() {
526            if line.width > 0.0 {
527                line_boxes.push((line_offset(line), line.width, index as f32 * line_advance));
528            }
529        }
530        for (key, origin, scale, pixels) in placed {
531            let alpha = outlines[&key].draw(origin, scale, pixels);
532            place_glyph(
533                &mut coverage,
534                width,
535                height,
536                pixels.left + i64::from(margin_x),
537                pixels.top + i64::from(margin_y),
538                usize::try_from(pixels.right - pixels.left).unwrap_or(0),
539                &alpha,
540            );
541        }
542        let rgba8 = compose(request, &coverage, width, [margin_x, margin_y], &line_boxes);
543        let texture = HostTexture {
544            content_version: request.content_version,
545            width,
546            height,
547            premultiplication: Premultiplication::Premultiplied,
548            color_space: RasterColorSpace::Srgb,
549        };
550        Ok(TextRasterResult {
551            descriptor: TextTexture {
552                texture: texture.clone(),
553                layout_x: margin_x as f32,
554                layout_y: margin_y as f32,
555                layout_width,
556                layout_height,
557                first_baseline: ascent,
558                ascent: ink_ascent,
559                descent: ink_descent,
560                line_count,
561            },
562            pixels: HostTexturePixels { texture, rgba8 },
563        })
564    }
565
566    /// Font indices in the order a request tries them: the fonts of
567    /// `family` first, then the others, each group in fallback order.
568    fn font_order(&self, family: Option<&str>) -> Vec<usize> {
569        let preferred = |font: &LoadedFont| {
570            family.is_some_and(|wanted| {
571                font.family
572                    .as_deref()
573                    .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
574            })
575        };
576        let (mut order, rest): (Vec<usize>, Vec<usize>) =
577            (0..self.fonts.len()).partition(|&index| preferred(&self.fonts[index]));
578        order.extend(rest);
579        order
580    }
581}
582
583/// The stack's faces for one request, each parsed once; a face's shaping
584/// tables only when a run shapes with it.
585struct Faces<'a> {
586    parsed: Vec<ttf_parser::Face<'a>>,
587    shapers: Vec<OnceCell<rustybuzz::Face<'a>>>,
588}
589
590impl<'a> Faces<'a> {
591    fn new(fonts: &'a [LoadedFont]) -> Result<Self, TextRasterError> {
592        let parsed = fonts
593            .iter()
594            .map(|font| {
595                ttf_parser::Face::parse(&font.source.bytes, font.source.face_index)
596                    .ok()
597                    .filter(|face| face.units_per_em() > 0)
598                    .ok_or_else(|| TextRasterError::InvalidFont(font.source.family.clone()))
599            })
600            .collect::<Result<Vec<_>, _>>()?;
601        Ok(Self {
602            shapers: parsed.iter().map(|_| OnceCell::new()).collect(),
603            parsed,
604        })
605    }
606
607    fn shaper(&self, font: usize) -> &rustybuzz::Face<'a> {
608        self.shapers[font].get_or_init(|| rustybuzz::Face::from_face(self.parsed[font].clone()))
609    }
610
611    #[allow(clippy::cast_precision_loss)]
612    fn units_per_em(&self, font: usize) -> f32 {
613        f32::from(self.parsed[font].units_per_em())
614    }
615
616    /// Whether the font maps `character` to a glyph of its own.
617    fn has(&self, font: usize, character: char) -> bool {
618        self.parsed[font]
619            .glyph_index(character)
620            .is_some_and(|glyph| glyph.0 != 0)
621    }
622
623    /// Whether the font has every character of `grapheme` that is drawn at all.
624    fn covers(&self, font: usize, grapheme: &str) -> bool {
625        grapheme
626            .chars()
627            .filter(|&character| !is_default_ignorable(character))
628            .all(|character| self.has(font, character))
629    }
630}
631
632/// The text as layout reads it: under [`EmojiPolicy::Replace`] each emoji
633/// grapheme one replacement character, every line or paragraph separator a
634/// `\n`, and a tab a space (a caption has no tab stops).
635///
636/// U+2028 LINE SEPARATOR, which UAX #9 does not count as a paragraph end,
637/// ends one here too, so every line break is one path.
638fn normalize(text: &str, policy: EmojiPolicy) -> String {
639    let mut normalized = String::with_capacity(text.len());
640    for grapheme in text.graphemes(true) {
641        if policy == EmojiPolicy::Replace && is_emoji(grapheme) {
642            normalized.push('\u{fffd}');
643        } else if grapheme == "\r\n" {
644            normalized.push('\n');
645        } else {
646            normalized.extend(grapheme.chars().map(|character| match character {
647                '\r'
648                | '\u{b}'
649                | '\u{c}'
650                | '\u{1c}'..='\u{1e}'
651                | '\u{85}'
652                | '\u{2028}'
653                | '\u{2029}' => '\n',
654                '\t' => ' ',
655                other => other,
656            }));
657        }
658    }
659    normalized
660}
661
662/// A grapheme shown as an emoji (UTS #51), as `OffscreenTextRasterizer`
663/// reads one too: unless U+FE0E asks for text, one holding an
664/// `Emoji_Presentation` character (regional indicators and skin tones
665/// included), or U+FE0F with a pictograph or a keycap. A symbol shown as
666/// text by default, such as (c) or a sun, is not one: a text font draws it.
667fn is_emoji(grapheme: &str) -> bool {
668    if grapheme.contains('\u{fe0e}') {
669        return false;
670    }
671    let selected = grapheme.contains('\u{fe0f}');
672    grapheme.chars().any(|character| {
673        in_table(unicode_tables::EMOJI_PRESENTATION, character)
674            || (selected
675                && (character == '\u{20e3}'
676                    || in_table(unicode_tables::EXTENDED_PICTOGRAPHIC, character)))
677    })
678}
679
680fn is_default_ignorable(character: char) -> bool {
681    in_table(unicode_tables::DEFAULT_IGNORABLE, character)
682}
683
684fn in_table(table: &[(u32, u32)], character: char) -> bool {
685    let code = u32::from(character);
686    table
687        .binary_search_by(|&(first, last)| {
688            if last < code {
689                Ordering::Less
690            } else if first > code {
691                Ordering::Greater
692            } else {
693                Ordering::Equal
694            }
695        })
696        .is_ok()
697}
698
699/// A space a line may end in: it takes no width there, hanging past the
700/// end. Not the no-break spaces, which hold their neighbours together, nor
701/// the Ogham space mark, which is drawn.
702fn hangs(character: char) -> bool {
703    character.is_whitespace()
704        && !matches!(character, '\u{a0}' | '\u{2007}' | '\u{202f}' | '\u{1680}')
705}
706
707fn is_hangul(character: char) -> bool {
708    matches!(
709        character,
710        '\u{1100}'..='\u{11ff}'
711            | '\u{3130}'..='\u{318f}'
712            | '\u{a960}'..='\u{a97f}'
713            | '\u{ac00}'..='\u{d7a3}'
714            | '\u{d7b0}'..='\u{d7ff}'
715    )
716}
717
718/// One line of a paragraph, laid out.
719struct LaidLine {
720    /// Glyphs left to right, pens from the line's start.
721    glyphs: Vec<PositionedGlyph>,
722    /// Width of what it draws: its trailing spaces hang past it.
723    width: f32,
724    /// The paragraph runs right to left.
725    rtl: bool,
726    /// It has glyphs, even if none with ink.
727    has_glyphs: bool,
728}
729
730/// Lays out one paragraph: shaped run by run in logical order, broken into
731/// lines no wider than `max_width` (none when it is not positive), and each
732/// line put in visual order.
733fn layout_paragraph(
734    faces: &Faces<'_>,
735    paragraph: &str,
736    font_size: f32,
737    max_width: f32,
738    order: &[usize],
739) -> Vec<LaidLine> {
740    let bidi = ParagraphBidiInfo::new(paragraph, None);
741    let rtl = bidi.paragraph_level.is_rtl();
742    let glyphs = shape_paragraph(faces, paragraph, &bidi, font_size, order);
743    let clusters = clusters(&glyphs, paragraph);
744    break_lines(&clusters, max_width)
745        .into_iter()
746        .map(|range| {
747            let has_glyphs = !range.is_empty();
748            // Trailing spaces hang past the end: no width, nothing drawn.
749            let mut end = range.end;
750            while end > range.start && clusters[end - 1].hangs {
751                end -= 1;
752            }
753            let mut line = LaidLine {
754                glyphs: Vec::new(),
755                width: 0.0,
756                rtl,
757                has_glyphs,
758            };
759            if end == range.start {
760                return line;
761            }
762            let bytes = clusters[range.start].start
763                ..clusters
764                    .get(range.end)
765                    .map_or(paragraph.len(), |next| next.start);
766            let levels = bidi.reordered_levels(bytes);
767            let mut x = 0.0_f32;
768            for index in visual_order(
769                clusters[range.start].glyphs.start..clusters[end - 1].glyphs.end,
770                |index| levels[glyphs[index].cluster].number(),
771            ) {
772                let glyph = &glyphs[index];
773                line.glyphs.push(PositionedGlyph {
774                    font: glyph.font,
775                    glyph_index: glyph.glyph_index,
776                    x,
777                    x_offset: glyph.x_offset,
778                    y_offset: glyph.y_offset,
779                });
780                x += glyph.advance;
781            }
782            line.width = x;
783            line
784        })
785        .collect()
786}
787
788/// The glyphs of `range`, in logical order, in visual order (UAX #9 L2):
789/// from the highest level down to the lowest odd one, every run at that
790/// level or above reversed. A right-to-left run comes back to the order its
791/// shaper gave.
792fn visual_order(range: Range<usize>, level: impl Fn(usize) -> u8) -> Vec<usize> {
793    let mut order = range.collect::<Vec<_>>();
794    let levels = order.iter().map(|&index| level(index)).collect::<Vec<_>>();
795    let highest = levels.iter().copied().max().unwrap_or(0);
796    let Some(lowest_odd) = levels.iter().copied().filter(|level| level % 2 == 1).min() else {
797        return order;
798    };
799    let mut current = levels;
800    for at_least in (lowest_odd..=highest).rev() {
801        let mut start = 0;
802        while start < order.len() {
803            if current[start] < at_least {
804                start += 1;
805                continue;
806            }
807            let end = (start..order.len())
808                .find(|&index| current[index] < at_least)
809                .unwrap_or(order.len());
810            order[start..end].reverse();
811            current[start..end].reverse();
812            start = end;
813        }
814    }
815    order
816}
817
818/// One stretch of a paragraph shaped as a unit: one direction, one
819/// script, one font.
820#[derive(Clone, Debug, PartialEq)]
821struct Run {
822    range: Range<usize>,
823    font: usize,
824    script: Option<Script>,
825    rtl: bool,
826}
827
828/// Splits a paragraph into runs. Each grapheme takes the first font in
829/// `order` with all it draws; one of only common characters (spaces, digits,
830/// punctuation) stays in the font before it when that has them, and one no
831/// font has takes the font of its base character, else the font before it.
832/// A grapheme of common characters takes the script before it, or after it
833/// at the paragraph's start.
834fn itemize(
835    faces: &Faces<'_>,
836    paragraph: &str,
837    bidi: &ParagraphBidiInfo<'_>,
838    order: &[usize],
839) -> Vec<Run> {
840    let first = order.first().copied().unwrap_or(0);
841    let mut graphemes = Vec::<(usize, usize, Option<Script>, bool)>::new();
842    let mut current = None;
843    for (start, grapheme) in paragraph.grapheme_indices(true) {
844        let script = grapheme
845            .chars()
846            .map(|character| character.script())
847            .find(|script| !matches!(script, Script::Common | Script::Inherited | Script::Unknown));
848        let stays = current.filter(|&font| script.is_none() && faces.covers(font, grapheme));
849        let font = stays
850            .or_else(|| {
851                order
852                    .iter()
853                    .copied()
854                    .find(|&font| faces.covers(font, grapheme))
855            })
856            .or_else(|| {
857                let base = grapheme
858                    .chars()
859                    .find(|&character| !is_default_ignorable(character))?;
860                order.iter().copied().find(|&font| faces.has(font, base))
861            })
862            .or(current)
863            .unwrap_or(first);
864        current = Some(font);
865        graphemes.push((start, font, script, bidi.levels[start].is_rtl()));
866    }
867    let leading = graphemes.iter().find_map(|grapheme| grapheme.2);
868    let mut before = leading;
869    for grapheme in &mut graphemes {
870        match grapheme.2 {
871            Some(script) => before = Some(script),
872            None => grapheme.2 = before,
873        }
874    }
875    let mut runs = Vec::<Run>::new();
876    for (index, &(start, font, script, rtl)) in graphemes.iter().enumerate() {
877        let end = graphemes
878            .get(index + 1)
879            .map_or(paragraph.len(), |next| next.0);
880        match runs.last_mut() {
881            Some(run) if run.font == font && run.script == script && run.rtl == rtl => {
882                run.range.end = end;
883            }
884            _ => runs.push(Run {
885                range: start..end,
886                font,
887                script,
888                rtl,
889            }),
890        }
891    }
892    runs
893}
894
895/// The rustybuzz script of a Unicode one, by its ISO 15924 code.
896fn shaping_script(script: Script) -> Option<rustybuzz::Script> {
897    let code: [u8; 4] = script.short_name().as_bytes().try_into().ok()?;
898    rustybuzz::Script::from_iso15924_tag(ttf_parser::Tag::from_bytes(&code))
899}
900
901/// Shapes a paragraph run by run, each in its own direction and script
902/// with the text around it as context, into glyphs in logical order.
903fn shape_paragraph(
904    faces: &Faces<'_>,
905    paragraph: &str,
906    bidi: &ParagraphBidiInfo<'_>,
907    font_size: f32,
908    order: &[usize],
909) -> Vec<ShapedGlyph> {
910    let mut glyphs = Vec::new();
911    for run in itemize(faces, paragraph, bidi, order) {
912        let face = faces.shaper(run.font);
913        let units = faces.units_per_em(run.font);
914        let mut buffer = UnicodeBuffer::new();
915        buffer.push_str(&paragraph[run.range.clone()]);
916        buffer.set_pre_context(&paragraph[..run.range.start]);
917        buffer.set_post_context(&paragraph[run.range.end..]);
918        buffer.set_direction(if run.rtl {
919            Direction::RightToLeft
920        } else {
921            Direction::LeftToRight
922        });
923        match run.script.and_then(shaping_script) {
924            Some(script) => buffer.set_script(script),
925            None => buffer.guess_segment_properties(),
926        }
927        let shaped = rustybuzz::shape(face, &[], buffer);
928        let first = glyphs.len();
929        for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
930            #[allow(clippy::cast_precision_loss)]
931            glyphs.push(ShapedGlyph {
932                font: run.font,
933                // A font's own substitutions (an AAT `morx` table) may name a glyph it does not
934                // have; that draws as `.notdef`.
935                glyph_index: u16::try_from(info.glyph_id)
936                    .ok()
937                    .filter(|&glyph| glyph < face.number_of_glyphs())
938                    .unwrap_or(0),
939                cluster: run.range.start + usize::try_from(info.cluster).unwrap_or(0),
940                advance: position.x_advance as f32 * font_size / units,
941                x_offset: position.x_offset as f32 * font_size / units,
942                y_offset: position.y_offset as f32 * font_size / units,
943            });
944        }
945        // The shaper gives a right-to-left run in visual order; the paragraph is broken in logical
946        // order.
947        if run.rtl {
948            glyphs[first..].reverse();
949        }
950    }
951    glyphs
952}
953
954#[derive(Copy, Clone, Debug)]
955struct ShapedGlyph {
956    font: usize,
957    glyph_index: u16,
958    /// Byte offset in the paragraph of the text it draws.
959    cluster: usize,
960    advance: f32,
961    /// Where the shaper places the glyph off its pen position, in pixels, y
962    /// up: a mark on its base's anchor, for one.
963    x_offset: f32,
964    y_offset: f32,
965}
966
967/// The glyphs of one cluster: what a line never splits.
968#[derive(Clone, Debug, PartialEq)]
969struct Cluster {
970    glyphs: Range<usize>,
971    /// Byte offset in the paragraph of its text.
972    start: usize,
973    width: f32,
974    /// All it draws is spaces that hang at a line's end.
975    hangs: bool,
976    /// UAX #14 lets a line break before it.
977    break_before: bool,
978}
979
980/// Groups logical-order glyphs into clusters, with where UAX #14 lets a
981/// line break: except between two Hangul characters, since Korean breaks
982/// between words (the tailoring UAX #14 gives for it).
983fn clusters(glyphs: &[ShapedGlyph], paragraph: &str) -> Vec<Cluster> {
984    let mut opportunities = unicode_linebreak::linebreaks(paragraph)
985        .filter(|&(at, kind)| {
986            kind == BreakOpportunity::Allowed
987                && !(paragraph[..at].chars().next_back().is_some_and(is_hangul)
988                    && paragraph[at..].chars().next().is_some_and(is_hangul))
989        })
990        .map(|(at, _)| at)
991        .peekable();
992    let mut clusters = Vec::<Cluster>::new();
993    for (index, glyph) in glyphs.iter().enumerate() {
994        match clusters.last_mut() {
995            Some(cluster) if cluster.start == glyph.cluster => {
996                cluster.glyphs.end = index + 1;
997                cluster.width += glyph.advance;
998            }
999            _ => clusters.push(Cluster {
1000                glyphs: index..index + 1,
1001                start: glyph.cluster,
1002                width: glyph.advance,
1003                hangs: false,
1004                break_before: false,
1005            }),
1006        }
1007    }
1008    for index in 0..clusters.len() {
1009        let start = clusters[index].start;
1010        let end = clusters
1011            .get(index + 1)
1012            .map(|next| next.start)
1013            .filter(|&end| end > start)
1014            .unwrap_or_else(|| {
1015                paragraph[start..]
1016                    .chars()
1017                    .next()
1018                    .map_or(start, |character| start + character.len_utf8())
1019            });
1020        clusters[index].hangs = paragraph[start..end].chars().all(hangs);
1021        let mut found = false;
1022        while let Some(&at) = opportunities.peek() {
1023            if at > start {
1024                break;
1025            }
1026            opportunities.next();
1027            found = true;
1028        }
1029        clusters[index].break_before = index > 0 && found;
1030    }
1031    clusters
1032}
1033
1034/// Splits a paragraph's clusters into lines no wider than `max_width` (none
1035/// when it is not positive), as cluster ranges.
1036///
1037/// A line breaks at its last opportunity that keeps it within the width;
1038/// what is still wider breaks between clusters. Spaces never push a line
1039/// over: they hang past its end.
1040fn break_lines(clusters: &[Cluster], max_width: f32) -> Vec<Range<usize>> {
1041    let mut lines = Vec::new();
1042    let mut start = 0;
1043    let mut x = 0.0_f32;
1044    // The latest cluster a line may start at, and the pen position there.
1045    let mut opportunity: Option<(usize, f32)> = None;
1046    for (index, cluster) in clusters.iter().enumerate() {
1047        if cluster.break_before && index > start {
1048            opportunity = Some((index, x));
1049        }
1050        if max_width > 0.0 && !cluster.hangs && index > start && x + cluster.width > max_width {
1051            let (end, width) = opportunity.unwrap_or((index, x));
1052            lines.push(start..end);
1053            start = end;
1054            x -= width;
1055            opportunity = None;
1056            // What moved down with this cluster is wider than a line: it breaks again before the
1057            // cluster.
1058            if index > start && x + cluster.width > max_width {
1059                lines.push(start..index);
1060                start = index;
1061                x = 0.0;
1062            }
1063        }
1064        x += cluster.width;
1065    }
1066    lines.push(start..clusters.len());
1067    lines
1068}
1069
1070#[derive(Copy, Clone, Debug)]
1071struct PositionedGlyph {
1072    font: usize,
1073    glyph_index: u16,
1074    /// Pen position from the line's start.
1075    x: f32,
1076    /// The shaper's placement of the glyph off its pen position, in pixels, y up.
1077    x_offset: f32,
1078    y_offset: f32,
1079}
1080
1081/// A glyph's outline in font units, y up, as the font draws it.
1082#[derive(Default)]
1083struct Outline {
1084    segments: Vec<Segment>,
1085    /// `[x_min, y_min, x_max, y_max]` of every point, on the curves and off
1086    /// them, so holding the curves; `None` when it draws nothing.
1087    bounds: Option<[f32; 4]>,
1088}
1089
1090enum Segment {
1091    Line([f32; 2], [f32; 2]),
1092    Quad([f32; 2], [f32; 2], [f32; 2]),
1093    Cubic([f32; 2], [f32; 2], [f32; 2], [f32; 2]),
1094}
1095
1096/// The pixels a glyph's outline reaches, right and bottom exclusive, in
1097/// the layout box, y down.
1098#[derive(Copy, Clone, Debug)]
1099struct PixelBox {
1100    left: i64,
1101    top: i64,
1102    right: i64,
1103    bottom: i64,
1104}
1105
1106impl PixelBox {
1107    /// Where outline `bounds` land with the glyph's origin at `origin`
1108    /// (layout pixels, y down) at `scale` pixels a font unit.
1109    #[allow(clippy::cast_possible_truncation)]
1110    fn of(bounds: [f32; 4], origin: [f32; 2], scale: f32) -> Self {
1111        let [x_min, y_min, x_max, y_max] = bounds;
1112        // Each edge the same expression `Outline::draw` maps a point by, so every point lands
1113        // inside.
1114        Self {
1115            left: (origin[0] + x_min * scale).floor() as i64,
1116            top: (origin[1] - y_max * scale).floor() as i64,
1117            right: (origin[0] + x_max * scale).ceil() as i64,
1118            bottom: (origin[1] - y_min * scale).ceil() as i64,
1119        }
1120    }
1121}
1122
1123#[cfg(test)]
1124thread_local! {
1125    /// How many glyph outlines this thread has read from a face.
1126    static OUTLINES_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1127}
1128
1129impl Outline {
1130    fn of(face: &ttf_parser::Face<'_>, glyph: u16) -> Self {
1131        #[cfg(test)]
1132        OUTLINES_READ.with(|read| read.set(read.get() + 1));
1133        let mut sink = OutlineSink::default();
1134        face.outline_glyph(GlyphId(glyph), &mut sink);
1135        sink.close();
1136        sink.outline
1137    }
1138
1139    /// The glyph's coverage over `pixels`, row by row, with its origin at
1140    /// `origin` at `scale`.
1141    #[allow(
1142        clippy::cast_possible_truncation,
1143        clippy::cast_sign_loss,
1144        clippy::cast_precision_loss
1145    )]
1146    fn draw(&self, origin: [f32; 2], scale: f32, pixels: PixelBox) -> Vec<u8> {
1147        let width = usize::try_from(pixels.right - pixels.left).unwrap_or(0);
1148        let height = usize::try_from(pixels.bottom - pixels.top).unwrap_or(0);
1149        let mut raster = Rasterizer::new(width, height);
1150        let (left, top) = (pixels.left as f32, pixels.top as f32);
1151        let at =
1152            |[x, y]: [f32; 2]| point(origin[0] + x * scale - left, (origin[1] - y * scale) - top);
1153        for segment in &self.segments {
1154            match *segment {
1155                Segment::Line(from, to) => raster.draw_line(at(from), at(to)),
1156                Segment::Quad(from, control, to) => raster.draw_quad(at(from), at(control), at(to)),
1157                Segment::Cubic(from, first, second, to) => {
1158                    raster.draw_cubic(at(from), at(first), at(second), at(to));
1159                }
1160            }
1161        }
1162        let mut alpha = vec![0_u8; width * height];
1163        raster.for_each_pixel(|index, coverage| {
1164            alpha[index] = (coverage.min(1.0) * 255.0).round() as u8;
1165        });
1166        alpha
1167    }
1168}
1169
1170/// Collects an outline from ttf-parser, closing every contour.
1171#[derive(Default)]
1172struct OutlineSink {
1173    outline: Outline,
1174    start: [f32; 2],
1175    pen: [f32; 2],
1176}
1177
1178impl OutlineSink {
1179    fn include(&mut self, points: &[[f32; 2]]) {
1180        for &[x, y] in points {
1181            let bounds = self.outline.bounds.get_or_insert([x, y, x, y]);
1182            *bounds = [
1183                bounds[0].min(x),
1184                bounds[1].min(y),
1185                bounds[2].max(x),
1186                bounds[3].max(y),
1187            ];
1188        }
1189    }
1190}
1191
1192impl OutlineBuilder for OutlineSink {
1193    fn move_to(&mut self, x: f32, y: f32) {
1194        self.close();
1195        self.start = [x, y];
1196        self.pen = [x, y];
1197    }
1198
1199    fn line_to(&mut self, x: f32, y: f32) {
1200        let to = [x, y];
1201        self.include(&[self.pen, to]);
1202        self.outline.segments.push(Segment::Line(self.pen, to));
1203        self.pen = to;
1204    }
1205
1206    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
1207        let (control, to) = ([x1, y1], [x, y]);
1208        self.include(&[self.pen, control, to]);
1209        self.outline
1210            .segments
1211            .push(Segment::Quad(self.pen, control, to));
1212        self.pen = to;
1213    }
1214
1215    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
1216        let (first, second, to) = ([x1, y1], [x2, y2], [x, y]);
1217        self.include(&[self.pen, first, second, to]);
1218        self.outline
1219            .segments
1220            .push(Segment::Cubic(self.pen, first, second, to));
1221        self.pen = to;
1222    }
1223
1224    fn close(&mut self) {
1225        // Exactly where it began: a contour that came back by its own last point needs no closing
1226        // line.
1227        if self.pen.map(f32::to_bits) != self.start.map(f32::to_bits) {
1228            self.line_to(self.start[0], self.start[1]);
1229        }
1230    }
1231}
1232
1233fn validate_request(request: &TextRasterRequest) -> Result<(), TextRasterError> {
1234    if request.font_size.is_finite()
1235        && request.font_size > 0.0
1236        && request.font_size <= MAX_FONT_SIZE
1237        && request.text.len() <= MAX_TEXT_BYTES
1238        && request.max_width.is_finite()
1239        && request.max_width <= 16_384.0
1240        && request.line_height.is_finite()
1241        && request.line_height > 0.0
1242        && request.line_height <= MAX_LINE_HEIGHT
1243        && request.content_version > 0
1244        && request.outline.is_none_or(|outline| {
1245            outline.width.is_finite() && outline.width > 0.0 && outline.width <= MAX_OUTLINE_WIDTH
1246        })
1247        && request.background.is_none_or(|background| {
1248            background.padding.is_finite()
1249                && background.padding >= 0.0
1250                && background.padding <= MAX_BACKGROUND_PADDING
1251        })
1252    {
1253        Ok(())
1254    } else {
1255        Err(TextRasterError::InvalidRequest)
1256    }
1257}
1258
1259fn checked_dimension(value: f32) -> Result<u32, TextRasterError> {
1260    if value.is_finite() && value > 0.0 && value.ceil() <= 16_384.0 {
1261        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1262        Ok(value.ceil() as u32)
1263    } else {
1264        Err(TextRasterError::TooLarge)
1265    }
1266}
1267
1268/// Adds a glyph's coverage to the mask: where glyphs overlap, each pixel
1269/// keeps the most ink either gives it, so a glyph's empty corners never
1270/// erase its neighbour.
1271fn place_glyph(
1272    mask: &mut [u8],
1273    width: u32,
1274    height: u32,
1275    origin_x: i64,
1276    origin_y: i64,
1277    glyph_width: usize,
1278    alpha: &[u8],
1279) {
1280    for (index, coverage) in alpha.iter().copied().enumerate() {
1281        let x = origin_x + i64::try_from(index % glyph_width.max(1)).unwrap_or(0);
1282        let y = origin_y + i64::try_from(index / glyph_width.max(1)).unwrap_or(0);
1283        if x < 0 || y < 0 || x >= i64::from(width) || y >= i64::from(height) {
1284            continue;
1285        }
1286        let pixel =
1287            u64::try_from(y).unwrap_or(0) * u64::from(width) + u64::try_from(x).unwrap_or(0);
1288        let slot = &mut mask[usize::try_from(pixel).unwrap_or(0)];
1289        *slot = (*slot).max(coverage);
1290    }
1291}
1292
1293impl TextRasterRequest {
1294    /// Pixels the decorations reach past the layout box on every side when
1295    /// no ink does: the outline's disc and the background's padding, whole.
1296    /// The texture's margin ([`TextTexture::layout_x`]) is this plus room
1297    /// for ink past the box.
1298    #[must_use]
1299    pub fn decoration_margin(&self) -> u32 {
1300        let (outline, padding) = decoration_extents(self);
1301        outline.max(padding)
1302    }
1303}
1304
1305/// Pixels each decoration reaches past what it surrounds, whole: the
1306/// outline's disc past the ink, the background's padding past the box.
1307fn decoration_extents(request: &TextRasterRequest) -> (u32, u32) {
1308    let outline = request
1309        .outline
1310        .map_or(0, |outline| disc_extent(outline.width));
1311    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1312    let padding = request
1313        .background
1314        .map_or(0, |background| background.padding.ceil() as u32);
1315    (outline, padding)
1316}
1317
1318/// Integer radius of the disc an outline of `width` dilates by.
1319#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1320fn disc_extent(width: f32) -> u32 {
1321    (width + 0.5).ceil() as u32
1322}
1323
1324/// `color` at `coverage`, premultiplied: `(c * alpha, alpha)` with
1325/// `alpha = coverage * a`, each product truncated to a byte. The pinned
1326/// rasters depend on this truncation.
1327fn premultiplied(color: [u8; 4], coverage: u8) -> [u32; 4] {
1328    let alpha = u32::from(coverage) * u32::from(color[3]) / 255;
1329    [
1330        u32::from(color[0]) * alpha / 255,
1331        u32::from(color[1]) * alpha / 255,
1332        u32::from(color[2]) * alpha / 255,
1333        alpha,
1334    ]
1335}
1336
1337/// `source` over `destination`, premultiplied, rounded to the nearest byte.
1338fn over(source: [u32; 4], destination: [u32; 4]) -> [u32; 4] {
1339    let keep = 255 - source[3];
1340    std::array::from_fn(|channel| source[channel] + (destination[channel] * keep + 127) / 255)
1341}
1342
1343/// The text over its outline over its line boxes, premultiplied RGBA8, all
1344/// in integers so every target gives the same bytes. Undecorated text is its
1345/// coverage in its color alone.
1346#[allow(
1347    clippy::cast_possible_truncation,
1348    clippy::cast_sign_loss,
1349    clippy::cast_precision_loss
1350)]
1351fn compose(
1352    request: &TextRasterRequest,
1353    coverage: &[u8],
1354    width: u32,
1355    margin: [u32; 2],
1356    line_boxes: &[(f32, f32, f32)],
1357) -> Vec<u8> {
1358    let outline = request.outline.map(|outline| {
1359        (
1360            outline.color,
1361            dilate(
1362                coverage,
1363                width,
1364                u32::try_from(coverage.len() / width.max(1) as usize).unwrap_or(0),
1365                outline.width,
1366            ),
1367        )
1368    });
1369    let background = request.background.map(|background| {
1370        let [margin_x, margin_y] = margin.map(|margin| margin as f32);
1371        let padding = background.padding;
1372        let boxes = line_boxes
1373            .iter()
1374            .map(|&(left, line_width, top)| {
1375                (
1376                    margin_x + left - padding,
1377                    margin_x + left + line_width + padding,
1378                    margin_y + top - padding,
1379                    margin_y + top + request.font_size + padding,
1380                )
1381            })
1382            .collect::<Vec<_>>();
1383        (background.color, boxes)
1384    });
1385    let mut rgba8 = vec![0_u8; coverage.len() * 4];
1386    // The boxes that may meet the current row, `first..last`; boxes come in line order, so the
1387    // window only moves down.
1388    let (mut first, mut last) = (0, 0);
1389    for (index, text) in coverage.iter().copied().enumerate() {
1390        let mut pixel = [0_u32; 4];
1391        if let Some((color, boxes)) = &background {
1392            let x = (index % width as usize) as f32;
1393            let y = (index / width as usize) as f32;
1394            if index % width as usize == 0 {
1395                while first < boxes.len() && boxes[first].3 <= y {
1396                    first += 1;
1397                }
1398                last = last.max(first);
1399                while last < boxes.len() && boxes[last].2 < y + 1.0 {
1400                    last += 1;
1401                }
1402            }
1403            let covered = boxes[first..last]
1404                .iter()
1405                .map(|&(left, right, top, bottom)| {
1406                    let across = (right.min(x + 1.0) - left.max(x)).clamp(0.0, 1.0);
1407                    let down = (bottom.min(y + 1.0) - top.max(y)).clamp(0.0, 1.0);
1408                    across * down
1409                })
1410                .fold(0.0_f32, f32::max);
1411            pixel = premultiplied(*color, (covered * 255.0).round() as u8);
1412        }
1413        if let Some((color, band)) = &outline {
1414            pixel = over(premultiplied(*color, band[index]), pixel);
1415        }
1416        pixel = over(premultiplied(request.color, text), pixel);
1417        for (channel, value) in pixel.iter().enumerate() {
1418            rgba8[index * 4 + channel] = (*value).min(255) as u8;
1419        }
1420    }
1421    rgba8
1422}
1423
1424/// The coverage dilated by a disc of radius `width`: each pixel takes the
1425/// highest coverage within the disc, weighted by how much of the disc's edge
1426/// pixel lies inside it.
1427///
1428/// The work follows the ink, not the texture: only the ink's bounds grown by
1429/// the disc can be reached. The disc's fully weighted pixels are, row by row,
1430/// one run centred on the column, so they are a sliding-window maximum
1431/// (van Herk/Gil-Werman) of a coverage row; only the antialiased rim is
1432/// visited tap by tap. Every pixel is the same integer maximum a tap-by-tap
1433/// pass computes, so the band is byte for byte the same.
1434#[allow(
1435    clippy::cast_possible_truncation,
1436    clippy::cast_sign_loss,
1437    clippy::cast_precision_loss,
1438    clippy::cast_possible_wrap
1439)]
1440fn dilate(coverage: &[u8], width: u32, height: u32, radius: f32) -> Vec<u8> {
1441    let (width, height) = (width as usize, height as usize);
1442    let mut band = vec![0_u8; coverage.len()];
1443    let Some(ink) = ink_bounds(coverage, width, height) else {
1444        return band;
1445    };
1446    let disc = Disc::new(radius);
1447    let extent = disc.extent;
1448    let reach = Bounds {
1449        left: ink.left.saturating_sub(extent),
1450        top: ink.top.saturating_sub(extent),
1451        right: (ink.right + extent).min(width),
1452        bottom: (ink.bottom + extent).min(height),
1453    };
1454    // Each run half-width once per coverage row, laid onto every output row whose disc has that run
1455    // there.
1456    let span = reach.right - reach.left;
1457    let mut window = WindowMax::default();
1458    let mut maxima = vec![0_u8; span];
1459    for (half, offsets) in &disc.runs {
1460        for source_y in ink.top..ink.bottom {
1461            let row = &coverage[source_y * width..(source_y + 1) * width];
1462            window.of(row, reach.left, span, *half, &mut maxima);
1463            for &offset in offsets {
1464                // The tap `offset` rows below an output row reads this row.
1465                let Some(y) = source_y.checked_add_signed(-offset) else {
1466                    continue;
1467                };
1468                if y < reach.top || y >= reach.bottom {
1469                    continue;
1470                }
1471                let out = &mut band[y * width + reach.left..y * width + reach.right];
1472                for (pixel, highest) in out.iter_mut().zip(&maxima) {
1473                    *pixel = (*pixel).max(*highest);
1474                }
1475            }
1476        }
1477    }
1478    for &(dx, dy, weight) in &disc.rim {
1479        for source_y in ink.top..ink.bottom {
1480            let Some(y) = source_y.checked_add_signed(-dy) else {
1481                continue;
1482            };
1483            if y >= height {
1484                continue;
1485            }
1486            // Output columns x read x + dx; keep both within the texture and the source within the
1487            // ink.
1488            let first = ink.left.max(usize::try_from(dx).unwrap_or(0));
1489            let last = ink
1490                .right
1491                .min(usize::try_from(width as isize + dx.min(0)).unwrap_or(0));
1492            if first >= last {
1493                continue;
1494            }
1495            let source = &coverage[source_y * width + first..source_y * width + last];
1496            let start = y * width + (first as isize - dx) as usize;
1497            let out = &mut band[start..start + source.len()];
1498            for (pixel, value) in out.iter_mut().zip(source) {
1499                *pixel = (*pixel).max((u32::from(*value) * weight / 255) as u8);
1500            }
1501        }
1502    }
1503    band
1504}
1505
1506/// A rectangle of pixels, right and bottom exclusive.
1507#[derive(Copy, Clone, Debug, Eq, PartialEq)]
1508struct Bounds {
1509    left: usize,
1510    top: usize,
1511    right: usize,
1512    bottom: usize,
1513}
1514
1515/// The smallest rectangle holding every pixel with coverage, if any has.
1516fn ink_bounds(coverage: &[u8], width: usize, height: usize) -> Option<Bounds> {
1517    let mut bounds: Option<Bounds> = None;
1518    for y in 0..height {
1519        let row = &coverage[y * width..(y + 1) * width];
1520        let Some(left) = row.iter().position(|&value| value > 0) else {
1521            continue;
1522        };
1523        let right = row.iter().rposition(|&value| value > 0).unwrap_or(left) + 1;
1524        bounds = Some(bounds.map_or(
1525            Bounds {
1526                left,
1527                top: y,
1528                right,
1529                bottom: y + 1,
1530            },
1531            |bounds| Bounds {
1532                left: bounds.left.min(left),
1533                top: bounds.top,
1534                right: bounds.right.max(right),
1535                bottom: y + 1,
1536            },
1537        ));
1538    }
1539    bounds
1540}
1541
1542/// The taps of a disc of some radius, split into what a dilation reads as
1543/// runs and what it reads tap by tap.
1544struct Disc {
1545    /// Integer radius of the square holding the disc.
1546    extent: usize,
1547    /// Run half-widths with the row offsets whose fully weighted taps are
1548    /// exactly the columns `-half..=half`.
1549    runs: Vec<(usize, Vec<isize>)>,
1550    /// `(dx, dy, weight)` of each tap weighted below 255 and above 0.
1551    rim: Vec<(isize, isize, u32)>,
1552}
1553
1554impl Disc {
1555    /// The disc of `radius`: a tap's weight is how much of it lies within
1556    /// `radius + 0.5` of the centre, in 255ths, rounded.
1557    #[allow(
1558        clippy::cast_possible_truncation,
1559        clippy::cast_sign_loss,
1560        clippy::cast_precision_loss,
1561        clippy::cast_possible_wrap
1562    )]
1563    fn new(radius: f32) -> Self {
1564        let extent = disc_extent(radius) as isize;
1565        let mut runs: Vec<(usize, Vec<isize>)> = Vec::new();
1566        let mut rim = Vec::new();
1567        for dy in -extent..=extent {
1568            let mut half = None;
1569            for dx in -extent..=extent {
1570                let distance = ((dx * dx + dy * dy) as f32).sqrt();
1571                let weight = ((radius + 0.5 - distance).clamp(0.0, 1.0) * 255.0).round() as u32;
1572                match weight {
1573                    0 => {}
1574                    // Weight falls with distance, so a row's full taps are one run around its
1575                    // centre.
1576                    255 => half = Some(dx.unsigned_abs()),
1577                    _ => rim.push((dx, dy, weight)),
1578                }
1579            }
1580            if let Some(half) = half {
1581                match runs.iter_mut().find(|(run, _)| *run == half) {
1582                    Some((_, offsets)) => offsets.push(dy),
1583                    None => runs.push((half, vec![dy])),
1584                }
1585            }
1586        }
1587        Self {
1588            extent: extent as usize,
1589            runs,
1590            rim,
1591        }
1592    }
1593}
1594
1595/// Scratch for sliding-window maxima of a row (van Herk/Gil-Werman): the
1596/// maxima from each block's start and to each block's end.
1597#[derive(Default)]
1598struct WindowMax {
1599    padded: Vec<u8>,
1600    from_start: Vec<u8>,
1601    to_end: Vec<u8>,
1602}
1603
1604impl WindowMax {
1605    /// Into `out[i]`, the highest value of `row` over columns
1606    /// `left + i - half ..= left + i + half`, reading 0 off the row.
1607    fn of(&mut self, row: &[u8], left: usize, count: usize, half: usize, out: &mut [u8]) {
1608        let window = 2 * half + 1;
1609        let length = count + 2 * half;
1610        self.padded.clear();
1611        self.padded.resize(length, 0);
1612        // Padded index i is column left - half + i.
1613        let first_column = left.saturating_sub(half);
1614        let last_column = (left + count + half).min(row.len());
1615        if first_column < last_column {
1616            let at = first_column + half - left;
1617            self.padded[at..at + last_column - first_column]
1618                .copy_from_slice(&row[first_column..last_column]);
1619        }
1620        self.from_start.clear();
1621        self.from_start.resize(length, 0);
1622        self.to_end.clear();
1623        self.to_end.resize(length, 0);
1624        for block in (0..length).step_by(window) {
1625            let end = (block + window).min(length);
1626            let mut highest = 0;
1627            for index in block..end {
1628                highest = highest.max(self.padded[index]);
1629                self.from_start[index] = highest;
1630            }
1631            highest = 0;
1632            for index in (block..end).rev() {
1633                highest = highest.max(self.padded[index]);
1634                self.to_end[index] = highest;
1635            }
1636        }
1637        for (index, value) in out[..count].iter_mut().enumerate() {
1638            *value = self.to_end[index].max(self.from_start[index + window - 1]);
1639        }
1640    }
1641}
1642
1643#[cfg(test)]
1644mod tests;