Skip to main content

lumen_text/
lib.rs

1//! Text layout and GPU-facing text data for Lumen.
2//!
3//! This crate deliberately owns text shaping, measurement, atlas bookkeeping,
4//! and WGSL-facing buffer formats. Lumen nodes should depend on this layer
5//! rather than coupling directly to a specific renderer such as glyphon.
6
7use std::collections::HashMap;
8
9use cosmic_text::{
10    Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Family, FontSystem, Metrics, Shaping, Style,
11    SubpixelBin, SwashCache, SwashContent, SwashImage, Weight, Wrap,
12};
13
14mod gpu;
15#[cfg(feature = "experimental-msdf")]
16mod msdf;
17pub use gpu::*;
18#[cfg(feature = "experimental-msdf")]
19pub use msdf::*;
20
21pub const ALPHA_TEXT_SHADER: &str = include_str!("shaders/alpha_text.wgsl");
22#[cfg(feature = "experimental-msdf")]
23pub const MSDF_TEXT_SHADER: &str = include_str!("shaders/msdf_text.wgsl");
24#[cfg(feature = "experimental-msdf")]
25pub const MSDF_GENERATOR_SHADER: &str = include_str!("shaders/msdf_generate.wgsl");
26pub const DEFAULT_FONT_FAMILY: &str = "Roboto";
27pub const ROBOTO_REGULAR_BYTES: &[u8] = include_bytes!("../assets/roboto/Roboto-Regular.ttf");
28
29#[derive(Debug)]
30pub struct TextSystem {
31    font_system: FontSystem,
32    swash_cache: SwashCache,
33    #[cfg(feature = "experimental-msdf")]
34    msdf_job_cache: HashMap<GlyphKey, Option<MsdfGlyphJob>>,
35}
36
37impl TextSystem {
38    pub fn new() -> Self {
39        let mut font_system = FontSystem::new_with_fonts(std::iter::empty());
40        load_default_fonts(&mut font_system);
41        Self {
42            font_system,
43            swash_cache: SwashCache::new(),
44            #[cfg(feature = "experimental-msdf")]
45            msdf_job_cache: HashMap::new(),
46        }
47    }
48
49    pub fn load_font_data(&mut self, data: Vec<u8>) {
50        self.font_system.db_mut().load_font_data(data);
51        #[cfg(feature = "experimental-msdf")]
52        self.msdf_job_cache.clear();
53    }
54
55    pub fn measure(&mut self, request: &TextLayoutRequest) -> TextMeasurement {
56        self.layout(request).measurement
57    }
58
59    pub fn layout(&mut self, request: &TextLayoutRequest) -> TextLayout {
60        let metrics = Metrics::new(request.font_size.max(1.0), request.line_height());
61        let mut buffer = Buffer::new(&mut self.font_system, metrics);
62        buffer.set_size(
63            &mut self.font_system,
64            request.max_width.filter(|width| *width > 0.0),
65            None,
66        );
67        buffer.set_wrap(
68            &mut self.font_system,
69            if request.max_width.is_some_and(|width| width > 0.0) {
70                Wrap::WordOrGlyph
71            } else {
72                Wrap::None
73            },
74        );
75        let attrs = Attrs::new()
76            .family(Family::Name(&request.font_family))
77            .weight(Weight(request.font_weight.clamp(1, 1000)))
78            .style(match request.font_style {
79                TextFontStyle::Italic => Style::Italic,
80                TextFontStyle::Oblique => Style::Oblique,
81                TextFontStyle::Normal => Style::Normal,
82            })
83            .cache_key_flags(if request.disable_hinting {
84                CacheKeyFlags::DISABLE_HINTING
85            } else {
86                CacheKeyFlags::empty()
87            });
88        buffer.set_text(
89            &mut self.font_system,
90            &request.content,
91            &attrs,
92            Shaping::Advanced,
93            Some(match request.align {
94                TextAlign::Left => Align::Left,
95                TextAlign::Center => Align::Center,
96                TextAlign::Right => Align::Right,
97                TextAlign::Justified => Align::Justified,
98            }),
99        );
100        buffer.shape_until_scroll(&mut self.font_system, true);
101
102        let mut glyphs = Vec::new();
103        let mut width: f32 = 0.0;
104        let mut height: f32 = 0.0;
105        for run in buffer.layout_runs() {
106            width = width.max(run.line_w);
107            height = height.max(run.line_top + run.line_height);
108            for glyph in run.glyphs {
109                let physical =
110                    glyph.physical((request.origin[0], request.origin[1] + run.line_y), 1.0);
111                let mut cache_key = physical.cache_key;
112                cache_key.x_bin = SubpixelBin::Zero;
113                cache_key.y_bin = SubpixelBin::Zero;
114                let x = request.origin[0] + glyph.x + (glyph.font_size * glyph.x_offset);
115                let y =
116                    request.origin[1] + run.line_y + glyph.y - (glyph.font_size * glyph.y_offset);
117                glyphs.push(TextGlyph {
118                    key: GlyphKey(cache_key),
119                    x,
120                    y,
121                    width: glyph.w,
122                    height: run.line_height,
123                    x_offset: glyph.x_offset,
124                    y_offset: glyph.y_offset,
125                    color: request.color,
126                });
127            }
128        }
129
130        TextLayout {
131            measurement: TextMeasurement { width, height },
132            glyphs,
133        }
134    }
135
136    pub fn render_alpha_atlas(
137        &mut self,
138        layout: &TextLayout,
139        config: AtlasConfig,
140        max_glyphs: usize,
141    ) -> AlphaAtlasRender {
142        self.render_atlas(layout, config, max_glyphs)
143    }
144
145    #[cfg(feature = "experimental-msdf")]
146    pub fn render_gpu_hybrid_atlas(
147        &mut self,
148        layout: &TextLayout,
149        config: AtlasConfig,
150        max_glyphs: usize,
151        max_segments: usize,
152        max_msdf_pixels: u32,
153    ) -> GpuHybridAtlasRender {
154        let mut atlas = GlyphAtlas::new(config);
155        let mut pixels = vec![0_u8; config.width as usize * config.height as usize * 4];
156        let mut instances = Vec::with_capacity(max_glyphs.min(layout.glyphs.len()));
157        let mut jobs = Vec::new();
158        let mut segments = Vec::new();
159        let mut pixel_jobs = Vec::new();
160        let mut msdf_pixel_count = 0_u32;
161        let mut glyph_count = 0;
162
163        for glyph in layout.glyphs.iter().take(max_glyphs) {
164            let msdf_key = glyph.key.msdf_key();
165            if let Some(msdf) = self.glyph_msdf_job(msdf_key, config) {
166                let glyph_size = [msdf.placement.width, msdf.placement.height];
167                let glyph_pixels = glyph_size[0].saturating_mul(glyph_size[1]);
168                if !msdf.segments.is_empty()
169                    && segments.len().saturating_add(msdf.segments.len()) <= max_segments
170                    && msdf_pixel_count.saturating_add(glyph_pixels) <= max_msdf_pixels
171                {
172                    let Some(entry) = atlas.ensure_glyph(msdf_key, glyph_size) else {
173                        continue;
174                    };
175                    let segment_start = segments.len() as u32;
176                    let pixel_start = msdf_pixel_count;
177                    let job_index = jobs.len() as u32;
178                    segments.extend(msdf.segments);
179                    msdf_pixel_count = msdf_pixel_count.saturating_add(glyph_pixels);
180                    pixel_jobs.extend(std::iter::repeat(job_index).take(glyph_pixels as usize));
181                    jobs.push(GpuMsdfJob {
182                        atlas_rect: [
183                            entry.origin[0],
184                            entry.origin[1],
185                            entry.size[0],
186                            entry.size[1],
187                        ],
188                        segment_range: [segment_start, segments.len() as u32 - segment_start],
189                        pixel_range: [pixel_start, glyph_pixels],
190                        px_range: config.px_range.max(1) as f32,
191                        _padding: [0; 3],
192                    });
193                    instances.push(msdf_glyph_instance_for(glyph, entry, &msdf.placement));
194                } else {
195                    let Some(instance) =
196                        self.raster_glyph_instance(glyph, &mut atlas, config, &mut pixels)
197                    else {
198                        continue;
199                    };
200                    instances.push(instance);
201                }
202            } else {
203                let Some(instance) =
204                    self.raster_glyph_instance(glyph, &mut atlas, config, &mut pixels)
205                else {
206                    continue;
207                };
208                instances.push(instance);
209            }
210            glyph_count += 1;
211        }
212
213        GpuHybridAtlasRender {
214            atlas,
215            pixels,
216            instances,
217            jobs,
218            segments,
219            pixel_jobs,
220            msdf_pixel_count,
221            glyph_count,
222        }
223    }
224
225    fn render_atlas(
226        &mut self,
227        layout: &TextLayout,
228        config: AtlasConfig,
229        max_glyphs: usize,
230    ) -> AlphaAtlasRender {
231        let mut atlas = GlyphAtlas::new(config);
232        let mut pixels = vec![0_u8; config.width as usize * config.height as usize * 4];
233        let mut instances = Vec::with_capacity(max_glyphs.min(layout.glyphs.len()));
234        let mut glyph_count = 0;
235
236        for glyph in layout.glyphs.iter().take(max_glyphs) {
237            let Some(image) = self.glyph_image(glyph.key) else {
238                continue;
239            };
240            let glyph_size = [image.placement.width, image.placement.height];
241            let Some(entry) = atlas.ensure_glyph(glyph.key, glyph_size) else {
242                continue;
243            };
244            write_glyph_to_atlas(&mut pixels, config, entry, &image);
245            instances.push(glyph_instance_for(glyph, entry, &image));
246            glyph_count += 1;
247        }
248
249        AlphaAtlasRender {
250            atlas,
251            pixels,
252            instances,
253            glyph_count,
254        }
255    }
256
257    fn glyph_image(&mut self, key: GlyphKey) -> Option<SwashImage> {
258        self.swash_cache
259            .get_image(&mut self.font_system, key.0)
260            .as_ref()
261            .cloned()
262    }
263
264    #[cfg(feature = "experimental-msdf")]
265    fn glyph_msdf_job(&mut self, key: GlyphKey, config: AtlasConfig) -> Option<MsdfGlyphJob> {
266        if let Some(cached) = self.msdf_job_cache.get(&key) {
267            return cached.clone();
268        }
269        self.font_system
270            .db()
271            .with_face_data(key.0.font_id, |data, face_index| {
272                generate_msdf_job(data, face_index, key.0, config)
273            })
274            .flatten()
275            .inspect(|job| {
276                self.msdf_job_cache.insert(key, Some(job.clone()));
277            })
278            .or_else(|| {
279                self.msdf_job_cache.insert(key, None);
280                None
281            })
282    }
283
284    #[cfg(feature = "experimental-msdf")]
285    fn raster_glyph_instance(
286        &mut self,
287        glyph: &TextGlyph,
288        atlas: &mut GlyphAtlas,
289        config: AtlasConfig,
290        pixels: &mut [u8],
291    ) -> Option<GpuGlyphInstance> {
292        let image = self.glyph_image(glyph.key)?;
293        let glyph_size = [image.placement.width, image.placement.height];
294        let entry = atlas.ensure_glyph(glyph.key, glyph_size)?;
295        write_glyph_to_atlas(pixels, config, entry, &image);
296        Some(glyph_instance_for(glyph, entry, &image))
297    }
298}
299
300impl Default for TextSystem {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306#[cfg(feature = "experimental-msdf")]
307pub fn rgba8_to_rgba16_float(pixels: &[u8]) -> Vec<u16> {
308    pixels
309        .iter()
310        .map(|value| half::f16::from_f32(f32::from(*value) / 255.0).to_bits())
311        .collect()
312}
313
314fn load_default_fonts(font_system: &mut FontSystem) {
315    let db = font_system.db_mut();
316    db.load_font_data(ROBOTO_REGULAR_BYTES.to_vec());
317    db.set_sans_serif_family(DEFAULT_FONT_FAMILY);
318}
319
320#[derive(Debug, Clone)]
321pub struct TextLayoutRequest {
322    pub content: String,
323    pub font_family: String,
324    pub font_size: f32,
325    pub font_weight: u16,
326    pub font_style: TextFontStyle,
327    pub max_width: Option<f32>,
328    pub line_height: Option<f32>,
329    pub align: TextAlign,
330    pub origin: [f32; 2],
331    pub color: [f32; 4],
332    pub disable_hinting: bool,
333}
334
335impl TextLayoutRequest {
336    pub fn new(content: impl Into<String>) -> Self {
337        Self {
338            content: content.into(),
339            font_family: DEFAULT_FONT_FAMILY.to_string(),
340            font_size: 16.0,
341            font_weight: 400,
342            font_style: TextFontStyle::Normal,
343            max_width: None,
344            line_height: None,
345            align: TextAlign::Left,
346            origin: [0.0, 0.0],
347            color: [1.0, 1.0, 1.0, 1.0],
348            disable_hinting: true,
349        }
350    }
351
352    pub fn line_height(&self) -> f32 {
353        self.line_height.unwrap_or(self.font_size * 1.2).max(1.0)
354    }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum TextFontStyle {
359    Normal,
360    Italic,
361    Oblique,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum TextAlign {
366    Left,
367    Center,
368    Right,
369    Justified,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq)]
373pub struct TextMeasurement {
374    pub width: f32,
375    pub height: f32,
376}
377
378#[derive(Debug, Clone, PartialEq)]
379pub struct TextLayout {
380    pub measurement: TextMeasurement,
381    pub glyphs: Vec<TextGlyph>,
382}
383
384#[derive(Debug, Clone, PartialEq)]
385pub struct TextGlyph {
386    pub key: GlyphKey,
387    pub x: f32,
388    pub y: f32,
389    pub width: f32,
390    pub height: f32,
391    pub x_offset: f32,
392    pub y_offset: f32,
393    pub color: [f32; 4],
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
397pub struct GlyphKey(pub CacheKey);
398
399impl GlyphKey {
400    #[cfg(feature = "experimental-msdf")]
401    fn msdf_key(self) -> Self {
402        let mut key = self.0;
403        key.x_bin = cosmic_text::SubpixelBin::Zero;
404        key.y_bin = cosmic_text::SubpixelBin::Zero;
405        Self(key)
406    }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub struct AtlasConfig {
411    pub width: u32,
412    pub height: u32,
413    pub px_range: u32,
414}
415
416impl Default for AtlasConfig {
417    fn default() -> Self {
418        Self {
419            width: 2048,
420            height: 2048,
421            px_range: 8,
422        }
423    }
424}
425
426#[derive(Debug, Clone)]
427pub struct GlyphAtlas {
428    config: AtlasConfig,
429    entries: HashMap<GlyphKey, AtlasEntry>,
430    cursor_x: u32,
431    cursor_y: u32,
432    row_height: u32,
433}
434
435impl GlyphAtlas {
436    pub fn new(config: AtlasConfig) -> Self {
437        Self {
438            config,
439            entries: HashMap::new(),
440            cursor_x: 0,
441            cursor_y: 0,
442            row_height: 0,
443        }
444    }
445
446    pub fn config(&self) -> AtlasConfig {
447        self.config
448    }
449
450    pub fn used_size(&self) -> [u32; 2] {
451        [
452            self.config.width,
453            self.cursor_y
454                .saturating_add(self.row_height)
455                .min(self.config.height),
456        ]
457    }
458
459    pub fn entry(&self, key: &GlyphKey) -> Option<AtlasEntry> {
460        self.entries.get(key).copied()
461    }
462
463    pub fn ensure_glyph(&mut self, key: GlyphKey, size: [u32; 2]) -> Option<AtlasEntry> {
464        if let Some(entry) = self.entry(&key) {
465            return Some(entry);
466        }
467        let padding = self.config.px_range.max(1);
468        let width = size[0].saturating_add(padding * 2).max(1);
469        let height = size[1].saturating_add(padding * 2).max(1);
470        if width > self.config.width || height > self.config.height {
471            return None;
472        }
473        if self.cursor_x + width > self.config.width {
474            self.cursor_x = 0;
475            self.cursor_y = self.cursor_y.saturating_add(self.row_height);
476            self.row_height = 0;
477        }
478        if self.cursor_y + height > self.config.height {
479            return None;
480        }
481        let entry = AtlasEntry {
482            origin: [self.cursor_x + padding, self.cursor_y + padding],
483            size,
484            uv_min: [
485                (self.cursor_x + padding) as f32 / self.config.width as f32,
486                (self.cursor_y + padding) as f32 / self.config.height as f32,
487            ],
488            uv_max: [
489                (self.cursor_x + padding + size[0]) as f32 / self.config.width as f32,
490                (self.cursor_y + padding + size[1]) as f32 / self.config.height as f32,
491            ],
492        };
493        self.cursor_x += width;
494        self.row_height = self.row_height.max(height);
495        self.entries.insert(key, entry);
496        Some(entry)
497    }
498}
499
500#[derive(Debug, Clone)]
501pub struct AlphaAtlasRender {
502    pub atlas: GlyphAtlas,
503    pub pixels: Vec<u8>,
504    pub instances: Vec<GpuGlyphInstance>,
505    pub glyph_count: usize,
506}
507
508#[cfg(feature = "experimental-msdf")]
509#[derive(Debug, Clone)]
510pub struct GpuHybridAtlasRender {
511    pub atlas: GlyphAtlas,
512    pub pixels: Vec<u8>,
513    pub instances: Vec<GpuGlyphInstance>,
514    pub jobs: Vec<GpuMsdfJob>,
515    pub segments: Vec<GpuMsdfSegment>,
516    pub pixel_jobs: Vec<u32>,
517    pub msdf_pixel_count: u32,
518    pub glyph_count: usize,
519}
520
521#[derive(Debug, Clone, Copy, PartialEq)]
522pub struct AtlasEntry {
523    pub origin: [u32; 2],
524    pub size: [u32; 2],
525    pub uv_min: [f32; 2],
526    pub uv_max: [f32; 2],
527}
528
529fn write_glyph_to_atlas(
530    pixels: &mut [u8],
531    config: AtlasConfig,
532    entry: AtlasEntry,
533    image: &SwashImage,
534) {
535    match image.content {
536        SwashContent::Mask => {
537            for y in 0..image.placement.height as usize {
538                for x in 0..image.placement.width as usize {
539                    let src = y * image.placement.width as usize + x;
540                    let dst_x = entry.origin[0] as usize + x;
541                    let dst_y = entry.origin[1] as usize + y;
542                    let dst = (dst_y * config.width as usize + dst_x) * 4;
543                    let alpha = image.data[src];
544                    pixels[dst] = 255;
545                    pixels[dst + 1] = 255;
546                    pixels[dst + 2] = 255;
547                    pixels[dst + 3] = alpha;
548                }
549            }
550        }
551        SwashContent::Color => {
552            for y in 0..image.placement.height as usize {
553                for x in 0..image.placement.width as usize {
554                    let src = (y * image.placement.width as usize + x) * 4;
555                    let dst_x = entry.origin[0] as usize + x;
556                    let dst_y = entry.origin[1] as usize + y;
557                    let dst = (dst_y * config.width as usize + dst_x) * 4;
558                    pixels[dst..dst + 4].copy_from_slice(&image.data[src..src + 4]);
559                }
560            }
561        }
562        SwashContent::SubpixelMask => {
563            for y in 0..image.placement.height as usize {
564                for x in 0..image.placement.width as usize {
565                    let src = (y * image.placement.width as usize + x) * 3;
566                    let dst_x = entry.origin[0] as usize + x;
567                    let dst_y = entry.origin[1] as usize + y;
568                    let dst = (dst_y * config.width as usize + dst_x) * 4;
569                    let alpha = image.data[src]
570                        .max(image.data[src + 1])
571                        .max(image.data[src + 2]);
572                    pixels[dst] = 255;
573                    pixels[dst + 1] = 255;
574                    pixels[dst + 2] = 255;
575                    pixels[dst + 3] = alpha;
576                }
577            }
578        }
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn measures_roboto_text() {
588        let mut system = TextSystem::new();
589        let mut request = TextLayoutRequest::new("External URL media");
590        request.font_size = 44.0;
591        request.max_width = Some(900.0);
592
593        let measurement = system.measure(&request);
594
595        assert!(measurement.width > 100.0);
596        assert!(measurement.height > 40.0);
597    }
598
599    #[test]
600    fn creates_gpu_glyph_instances_from_layout() {
601        let mut system = TextSystem::new();
602        let mut request = TextLayoutRequest::new("Roboto");
603        request.font_size = 32.0;
604        let layout = system.layout(&request);
605        let render = system.render_alpha_atlas(&layout, AtlasConfig::default(), 128);
606        let instance = render
607            .instances
608            .iter()
609            .find(|instance| instance.rect[2] > 0.0)
610            .expect("expected a visible glyph instance");
611
612        assert!(instance.rect[2] > 0.0);
613        assert!(instance.uv_rect[2] > instance.uv_rect[0]);
614    }
615
616    #[test]
617    fn wraps_text_to_max_width() {
618        let mut system = TextSystem::new();
619        let mut unwrapped = TextLayoutRequest::new("one two three four five");
620        unwrapped.font_size = 24.0;
621        let mut wrapped = unwrapped.clone();
622        wrapped.max_width = Some(80.0);
623
624        let wide = system.measure(&unwrapped);
625        let narrow = system.measure(&wrapped);
626
627        assert!(wide.width > narrow.width);
628        assert!(narrow.height > wide.height);
629    }
630
631    #[test]
632    fn alpha_atlas_contains_non_empty_pixels() {
633        let mut system = TextSystem::new();
634        let mut request = TextLayoutRequest::new("A");
635        request.font_size = 48.0;
636        let layout = system.layout(&request);
637        let render = system.render_alpha_atlas(
638            &layout,
639            AtlasConfig {
640                width: 128,
641                height: 128,
642                px_range: 2,
643            },
644            16,
645        );
646
647        assert_eq!(render.instances.len(), render.glyph_count);
648        assert!(render.glyph_count > 0);
649        assert!(render.pixels.chunks_exact(4).any(|pixel| pixel[3] > 0));
650    }
651
652    #[test]
653    fn alpha_atlas_preserves_color_glyph_pixels() {
654        let mut image = SwashImage::new();
655        image.content = SwashContent::Color;
656        image.placement.width = 2;
657        image.placement.height = 1;
658        image.data = vec![255, 0, 0, 200, 0, 128, 255, 180];
659        let entry = AtlasEntry {
660            origin: [1, 1],
661            size: [2, 1],
662            uv_min: [0.0, 0.0],
663            uv_max: [1.0, 1.0],
664        };
665        let config = AtlasConfig {
666            width: 4,
667            height: 4,
668            px_range: 1,
669        };
670        let mut pixels = vec![0_u8; config.width as usize * config.height as usize * 4];
671
672        write_glyph_to_atlas(&mut pixels, config, entry, &image);
673
674        let first = ((config.width + 1) * 4) as usize;
675        let second = first + 4;
676        assert_eq!(&pixels[first..first + 4], &[255, 0, 0, 200]);
677        assert_eq!(&pixels[second..second + 4], &[0, 128, 255, 180]);
678    }
679
680    #[test]
681    fn raster_keys_ignore_subpixel_position() {
682        let mut system = TextSystem::new();
683        let mut first = TextLayoutRequest::new("A");
684        first.origin = [0.1, 0.0];
685        let mut second = first.clone();
686        second.origin = [0.6, 0.0];
687
688        let first_glyph = system.layout(&first).glyphs[0].clone();
689        let second_glyph = system.layout(&second).glyphs[0].clone();
690
691        assert_eq!(first_glyph.key, second_glyph.key);
692        assert_ne!(first_glyph.x, second_glyph.x);
693    }
694
695    #[cfg(feature = "experimental-msdf")]
696    #[test]
697    fn hybrid_atlas_uses_msdf_for_outline_glyphs() {
698        let mut system = TextSystem::new();
699        let layout = system.layout(&TextLayoutRequest::new("A"));
700        let render =
701            system.render_gpu_hybrid_atlas(&layout, AtlasConfig::default(), 16, 4096, 4096);
702
703        assert!(render.instances.iter().any(is_msdf_instance));
704    }
705
706    #[cfg(feature = "experimental-msdf")]
707    #[test]
708    fn msdf_keys_ignore_subpixel_position() {
709        let mut system = TextSystem::new();
710        let mut first = TextLayoutRequest::new("A");
711        first.origin = [0.1, 0.0];
712        let mut second = first.clone();
713        second.origin = [0.6, 0.0];
714
715        let first_key = system.layout(&first).glyphs[0].key;
716        let second_key = system.layout(&second).glyphs[0].key;
717
718        assert_eq!(first_key, second_key);
719        assert_eq!(first_key.msdf_key(), second_key.msdf_key());
720    }
721
722    #[cfg(feature = "experimental-msdf")]
723    #[test]
724    fn msdf_placement_keeps_fractional_bearings() {
725        let mut system = TextSystem::new();
726        let mut request = TextLayoutRequest::new("B");
727        request.font_size = 48.3;
728        let layout = system.layout(&request);
729        let job = system
730            .glyph_msdf_job(layout.glyphs[0].key.msdf_key(), AtlasConfig::default())
731            .expect("expected outline glyph to produce an MSDF job");
732
733        assert_ne!(job.placement.top.fract(), 0.0);
734    }
735
736    #[cfg(feature = "experimental-msdf")]
737    #[test]
738    fn msdf_budget_fallback_uses_raster_atlas_entry() {
739        let mut system = TextSystem::new();
740        let mut request = TextLayoutRequest::new("A");
741        request.origin = [0.25, 0.0];
742        let layout = system.layout(&request);
743        let glyph_key = layout.glyphs[0].key;
744        let msdf_key = glyph_key.msdf_key();
745        assert_eq!(glyph_key, msdf_key);
746
747        let render =
748            system.render_gpu_hybrid_atlas(&layout, AtlasConfig::default(), 16, 0, u32::MAX);
749
750        assert!(render.jobs.is_empty());
751        assert!(
752            render
753                .instances
754                .iter()
755                .all(|instance| !is_msdf_instance(instance))
756        );
757        assert!(render.atlas.entry(&glyph_key).is_some());
758    }
759
760    #[test]
761    fn atlas_reuses_existing_glyph_entries() {
762        let key = {
763            let mut system = TextSystem::new();
764            let layout = system.layout(&TextLayoutRequest::new("A"));
765            layout.glyphs[0].key
766        };
767        let mut atlas = GlyphAtlas::new(AtlasConfig {
768            width: 64,
769            height: 64,
770            px_range: 2,
771        });
772
773        let first = atlas.ensure_glyph(key, [12, 16]).unwrap();
774        let second = atlas.ensure_glyph(key, [12, 16]).unwrap();
775
776        assert_eq!(first, second);
777    }
778
779    #[test]
780    fn gpu_structs_have_stable_sizes() {
781        assert_eq!(std::mem::size_of::<GpuTextGlobals>(), 16);
782        assert_eq!(std::mem::size_of::<GpuGlyphInstance>(), 64);
783        #[cfg(feature = "experimental-msdf")]
784        {
785            assert_eq!(std::mem::size_of::<GpuMsdfGlobals>(), 24);
786            assert_eq!(std::mem::size_of::<GpuMsdfJob>(), 48);
787            assert_eq!(std::mem::size_of::<GpuMsdfSegment>(), 48);
788        }
789    }
790
791    #[test]
792    fn exposes_wgsl_shader_sources() {
793        assert!(ALPHA_TEXT_SHADER.contains("textureSample"));
794        assert!(ALPHA_TEXT_SHADER.contains("@binding(4) var<uniform> paint: Paint"));
795        assert!(ALPHA_TEXT_SHADER.contains("sample_paint(in.position.xy)"));
796        #[cfg(feature = "experimental-msdf")]
797        {
798            assert!(MSDF_TEXT_SHADER.contains("median3"));
799            assert!(MSDF_TEXT_SHADER.contains("textureSample"));
800            assert!(MSDF_GENERATOR_SHADER.contains("pixel_jobs"));
801        }
802    }
803}