Skip to main content

loam_text/
lib.rs

1//! Printable ASCII (`0x20..=0x7E`) is pre-baked at a fixed atlas size; per-call
2//! sizes scale the quads bilinearly. HUD readouts only, not typographic or
3//! non-Latin text.
4
5pub mod glyph;
6pub mod pass;
7
8pub use pass::{TextDraw, TextPass};
9
10use std::collections::HashMap;
11
12pub use ab_glyph;
13use ab_glyph::{Font, FontRef, Glyph, GlyphId, Point, ScaleFont};
14use anyhow::{anyhow, Result};
15use bytemuck::{Pod, Zeroable};
16use wgpu::*;
17
18const ATLAS_SIZE: u32 = 1024;
19const ATLAS_FORMAT: TextureFormat = TextureFormat::R8Unorm;
20
21#[derive(Copy, Clone, Debug)]
22struct GlyphEntry {
23    uv_min: [f32; 2],
24    uv_max: [f32; 2],
25    px_width: f32,
26    px_height: f32,
27    h_advance: f32,
28    bearing_x: f32,
29    bearing_y: f32,
30}
31
32#[repr(C)]
33#[derive(Copy, Clone, Debug, Pod, Zeroable)]
34struct TextVertex {
35    pos: [f32; 2],
36    uv: [f32; 2],
37    color: [f32; 4],
38}
39
40#[repr(C)]
41#[derive(Copy, Clone, Debug, Pod, Zeroable)]
42struct TextUniforms {
43    viewport_size: [f32; 2],
44    _pad: [f32; 2],
45}
46
47pub struct TextMetrics {
48    advances: HashMap<char, f32>,
49    bake_size_px: f32,
50    line_height_px: f32,
51}
52
53impl TextMetrics {
54    pub fn new(font_bytes: &[u8], bake_size_px: f32) -> Result<Self> {
55        validate_bake_size(bake_size_px)?;
56        let font = FontRef::try_from_slice(font_bytes)
57            .map_err(|e| anyhow!("loam-text: failed to parse font: {e}"))?;
58        Ok(Self::from_font(&font, bake_size_px))
59    }
60
61    fn from_font(font: &FontRef<'_>, bake_size_px: f32) -> Self {
62        let scaled = font.as_scaled(bake_size_px);
63        let advances = (0x20u32..=0x7E)
64            .map(|code| {
65                let c = code as u8 as char;
66                (c, scaled.h_advance(font.glyph_id(c)))
67            })
68            .collect();
69        Self {
70            advances,
71            bake_size_px,
72            line_height_px: scaled.ascent() - scaled.descent() + scaled.line_gap(),
73        }
74    }
75
76    /// Measures advances and full line boxes, including the final line gap.
77    pub fn measure(&self, text: &str, size_px: f32) -> [f32; 2] {
78        let mut widest = 0.0_f32;
79        let mut line = 0.0_f32;
80        let mut lines = 1_u32;
81        for c in text.chars() {
82            if c == '\n' {
83                widest = widest.max(line);
84                line = 0.0;
85                lines += 1;
86                continue;
87            }
88            line += self.advances.get(&c).copied().unwrap_or(0.0);
89        }
90        let scale = size_px / self.bake_size_px;
91        [
92            widest.max(line) * scale,
93            lines as f32 * self.line_height_px * scale,
94        ]
95    }
96
97    pub fn bake_size_px(&self) -> f32 {
98        self.bake_size_px
99    }
100
101    /// At the bake size.
102    pub fn line_height_px(&self) -> f32 {
103        self.line_height_px
104    }
105}
106
107pub struct TextRenderer {
108    pipeline: RenderPipeline,
109    bind_group: BindGroup,
110    uniform_buf: Buffer,
111    glyphs: HashMap<char, GlyphEntry>,
112    metrics: TextMetrics,
113    ascent_px: f32,
114
115    vertex_buf: Buffer,
116    vertex_capacity: u64,
117    queued: Vec<TextVertex>,
118}
119
120impl TextRenderer {
121    pub fn new(
122        device: &Device,
123        queue: &Queue,
124        surface_format: TextureFormat,
125        font_bytes: &[u8],
126        bake_size_px: f32,
127    ) -> Result<Self> {
128        validate_bake_size(bake_size_px)?;
129        let font = FontRef::try_from_slice(font_bytes)
130            .map_err(|e| anyhow!("loam-text: failed to parse font: {e}"))?;
131
132        let atlas_tex = device.create_texture(&TextureDescriptor {
133            label: Some("loam-text atlas"),
134            size: Extent3d {
135                width: ATLAS_SIZE,
136                height: ATLAS_SIZE,
137                depth_or_array_layers: 1,
138            },
139            mip_level_count: 1,
140            sample_count: 1,
141            dimension: TextureDimension::D2,
142            format: ATLAS_FORMAT,
143            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
144            view_formats: &[],
145        });
146        let atlas_view = atlas_tex.create_view(&TextureViewDescriptor::default());
147        let atlas_sampler = device.create_sampler(&SamplerDescriptor {
148            label: Some("loam-text atlas sampler"),
149            address_mode_u: AddressMode::ClampToEdge,
150            address_mode_v: AddressMode::ClampToEdge,
151            address_mode_w: AddressMode::ClampToEdge,
152            mag_filter: FilterMode::Linear,
153            min_filter: FilterMode::Linear,
154            mipmap_filter: FilterMode::Nearest,
155            ..Default::default()
156        });
157
158        let baked = bake_ascii_atlas(&font, bake_size_px)?;
159        queue.write_texture(
160            TexelCopyTextureInfo {
161                texture: &atlas_tex,
162                mip_level: 0,
163                origin: Origin3d::ZERO,
164                aspect: TextureAspect::All,
165            },
166            &baked.pixels,
167            TexelCopyBufferLayout {
168                offset: 0,
169                bytes_per_row: Some(ATLAS_SIZE),
170                rows_per_image: Some(ATLAS_SIZE),
171            },
172            Extent3d {
173                width: ATLAS_SIZE,
174                height: ATLAS_SIZE,
175                depth_or_array_layers: 1,
176            },
177        );
178
179        let uniform_buf = device.create_buffer(&BufferDescriptor {
180            label: Some("loam-text uniforms"),
181            size: std::mem::size_of::<TextUniforms>() as u64,
182            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
183            mapped_at_creation: false,
184        });
185
186        let bgl = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
187            label: Some("loam-text bgl"),
188            entries: &[
189                BindGroupLayoutEntry {
190                    binding: 0,
191                    visibility: ShaderStages::VERTEX_FRAGMENT,
192                    ty: BindingType::Buffer {
193                        ty: BufferBindingType::Uniform,
194                        has_dynamic_offset: false,
195                        min_binding_size: None,
196                    },
197                    count: None,
198                },
199                BindGroupLayoutEntry {
200                    binding: 1,
201                    visibility: ShaderStages::FRAGMENT,
202                    ty: BindingType::Texture {
203                        sample_type: TextureSampleType::Float { filterable: true },
204                        view_dimension: TextureViewDimension::D2,
205                        multisampled: false,
206                    },
207                    count: None,
208                },
209                BindGroupLayoutEntry {
210                    binding: 2,
211                    visibility: ShaderStages::FRAGMENT,
212                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
213                    count: None,
214                },
215            ],
216        });
217        let bind_group = device.create_bind_group(&BindGroupDescriptor {
218            label: Some("loam-text bg"),
219            layout: &bgl,
220            entries: &[
221                BindGroupEntry {
222                    binding: 0,
223                    resource: uniform_buf.as_entire_binding(),
224                },
225                BindGroupEntry {
226                    binding: 1,
227                    resource: BindingResource::TextureView(&atlas_view),
228                },
229                BindGroupEntry {
230                    binding: 2,
231                    resource: BindingResource::Sampler(&atlas_sampler),
232                },
233            ],
234        });
235
236        let shader = device.create_shader_module(ShaderModuleDescriptor {
237            label: Some("loam-text shader"),
238            source: ShaderSource::Wgsl(WGSL_SHADER.into()),
239        });
240        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
241            label: Some("loam-text pipeline layout"),
242            bind_group_layouts: &[&bgl],
243            push_constant_ranges: &[],
244        });
245        let vertex_attrs = wgpu::vertex_attr_array![
246            0 => Float32x2,
247            1 => Float32x2,
248            2 => Float32x4,
249        ];
250        let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
251            label: Some("loam-text pipeline"),
252            layout: Some(&pipeline_layout),
253            vertex: VertexState {
254                module: &shader,
255                entry_point: Some("vs_main"),
256                buffers: &[VertexBufferLayout {
257                    array_stride: std::mem::size_of::<TextVertex>() as u64,
258                    step_mode: VertexStepMode::Vertex,
259                    attributes: &vertex_attrs,
260                }],
261                compilation_options: Default::default(),
262            },
263            fragment: Some(FragmentState {
264                module: &shader,
265                entry_point: Some("fs_main"),
266                targets: &[Some(ColorTargetState {
267                    format: surface_format,
268                    blend: Some(BlendState::ALPHA_BLENDING),
269                    write_mask: ColorWrites::ALL,
270                })],
271                compilation_options: Default::default(),
272            }),
273            primitive: PrimitiveState {
274                topology: PrimitiveTopology::TriangleList,
275                ..Default::default()
276            },
277            depth_stencil: None,
278            multisample: MultisampleState {
279                count: 1,
280                ..Default::default()
281            },
282            multiview: None,
283            cache: None,
284        });
285
286        let initial_capacity = 1024_u64;
287        let vertex_buf = device.create_buffer(&BufferDescriptor {
288            label: Some("loam-text vertices"),
289            size: initial_capacity * std::mem::size_of::<TextVertex>() as u64,
290            usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
291            mapped_at_creation: false,
292        });
293
294        Ok(Self {
295            pipeline,
296            bind_group,
297            uniform_buf,
298            glyphs: baked.glyphs,
299            metrics: baked.metrics,
300            ascent_px: baked.ascent_px,
301            vertex_buf,
302            vertex_capacity: initial_capacity,
303            queued: Vec::new(),
304        })
305    }
306
307    /// Queues straight-alpha RGBA text at the first line's ascender in viewport pixels.
308    pub fn queue(&mut self, text: &str, position: [f32; 2], size_px: f32, color: [f32; 4]) {
309        layout_text(
310            text,
311            position,
312            size_px,
313            color,
314            &self.glyphs,
315            self.metrics.bake_size_px,
316            self.metrics.line_height_px,
317            self.ascent_px,
318            &mut self.queued,
319        );
320    }
321
322    /// Draws and clears the text queue.
323    pub fn record(
324        &mut self,
325        device: &Device,
326        queue: &Queue,
327        encoder: &mut CommandEncoder,
328        view: &TextureView,
329        viewport_size: [f32; 2],
330    ) {
331        if self.queued.is_empty() {
332            return;
333        }
334        self.upload(device, queue, viewport_size);
335        let mut rp = encoder.begin_render_pass(&RenderPassDescriptor {
336            label: Some("loam-text pass"),
337            color_attachments: &[Some(RenderPassColorAttachment {
338                view,
339                depth_slice: None,
340                resolve_target: None,
341                ops: Operations {
342                    load: LoadOp::Load,
343                    store: StoreOp::Store,
344                },
345            })],
346            depth_stencil_attachment: None,
347            timestamp_writes: None,
348            occlusion_query_set: None,
349        });
350        rp.set_pipeline(&self.pipeline);
351        rp.set_bind_group(0, &self.bind_group, &[]);
352        rp.set_vertex_buffer(0, self.vertex_buf.slice(..));
353        rp.draw(0..self.queued.len() as u32, 0..1);
354        drop(rp);
355
356        self.queued.clear();
357    }
358
359    fn upload(&mut self, device: &Device, queue: &Queue, viewport_size: [f32; 2]) {
360        let uniforms = TextUniforms {
361            viewport_size,
362            _pad: [0.0; 2],
363        };
364        queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&uniforms));
365
366        let needed = self.queued.len() as u64;
367        if needed > self.vertex_capacity {
368            let mut new_cap = self.vertex_capacity.max(1);
369            while new_cap < needed {
370                new_cap *= 2;
371            }
372            self.vertex_buf = device.create_buffer(&BufferDescriptor {
373                label: Some("loam-text vertices (grown)"),
374                size: new_cap * std::mem::size_of::<TextVertex>() as u64,
375                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
376                mapped_at_creation: false,
377            });
378            self.vertex_capacity = new_cap;
379        }
380        queue.write_buffer(&self.vertex_buf, 0, bytemuck::cast_slice(&self.queued));
381    }
382
383    pub fn metrics(&self) -> &TextMetrics {
384        &self.metrics
385    }
386
387    pub fn bake_size_px(&self) -> f32 {
388        self.metrics.bake_size_px
389    }
390
391    pub fn line_height_px(&self) -> f32 {
392        self.metrics.line_height_px
393    }
394}
395
396#[allow(clippy::too_many_arguments)]
397fn layout_text(
398    text: &str,
399    position: [f32; 2],
400    size_px: f32,
401    color: [f32; 4],
402    glyphs: &HashMap<char, GlyphEntry>,
403    bake_size_px: f32,
404    line_height_px: f32,
405    ascent_px: f32,
406    out: &mut Vec<TextVertex>,
407) {
408    let scale = size_px / bake_size_px;
409    let line_h = line_height_px * scale;
410    let mut cursor_x = position[0];
411    let mut cursor_y = position[1];
412
413    for c in text.chars() {
414        if c == '\n' {
415            cursor_x = position[0];
416            cursor_y += line_h;
417            continue;
418        }
419        if !is_printable_ascii(c) {
420            continue;
421        }
422        let Some(g) = glyphs.get(&c) else {
423            continue;
424        };
425
426        let x0 = cursor_x + g.bearing_x * scale;
427        let y0 = cursor_y + (ascent_px + g.bearing_y) * scale;
428        let x1 = x0 + g.px_width * scale;
429        let y1 = y0 + g.px_height * scale;
430
431        let (u0, v0) = (g.uv_min[0], g.uv_min[1]);
432        let (u1, v1) = (g.uv_max[0], g.uv_max[1]);
433
434        out.extend_from_slice(&[
435            TextVertex {
436                pos: [x0, y0],
437                uv: [u0, v0],
438                color,
439            },
440            TextVertex {
441                pos: [x1, y0],
442                uv: [u1, v0],
443                color,
444            },
445            TextVertex {
446                pos: [x0, y1],
447                uv: [u0, v1],
448                color,
449            },
450            TextVertex {
451                pos: [x1, y0],
452                uv: [u1, v0],
453                color,
454            },
455            TextVertex {
456                pos: [x1, y1],
457                uv: [u1, v1],
458                color,
459            },
460            TextVertex {
461                pos: [x0, y1],
462                uv: [u0, v1],
463                color,
464            },
465        ]);
466
467        cursor_x += g.h_advance * scale;
468    }
469}
470
471fn is_printable_ascii(c: char) -> bool {
472    ('\u{20}'..='\u{7E}').contains(&c)
473}
474
475pub fn is_renderable(text: &str) -> bool {
476    text.chars().all(|c| c == '\n' || is_printable_ascii(c))
477}
478
479struct BakedAtlas {
480    pixels: Vec<u8>,
481    glyphs: HashMap<char, GlyphEntry>,
482    metrics: TextMetrics,
483    ascent_px: f32,
484}
485
486fn validate_bake_size(size: f32) -> Result<()> {
487    if !size.is_finite() || size <= 0.0 {
488        return Err(anyhow!(
489            "font bake size must be finite and positive, got {size}"
490        ));
491    }
492    Ok(())
493}
494
495fn bake_ascii_atlas(font: &FontRef<'_>, bake_size_px: f32) -> Result<BakedAtlas> {
496    let scaled = font.as_scaled(bake_size_px);
497
498    let mut atlas = vec![0u8; (ATLAS_SIZE * ATLAS_SIZE) as usize];
499    let mut entries: HashMap<char, GlyphEntry> = HashMap::with_capacity(96);
500
501    let pad = 1u32;
502    let mut shelf_y: u32 = pad;
503    let mut shelf_x: u32 = pad;
504    let mut shelf_h: u32 = 0;
505
506    for code in 0x20u32..=0x7E {
507        let c = code as u8 as char;
508        let gid: GlyphId = font.glyph_id(c);
509        let h_adv = scaled.h_advance(gid);
510
511        let mut glyph: Glyph = scaled.scaled_glyph(c);
512        glyph.position = Point { x: 0.0, y: 0.0 };
513
514        let outlined = scaled.outline_glyph(glyph);
515        match outlined {
516            Some(o) => {
517                let bounds = o.px_bounds();
518                let gw = bounds.width().ceil() as u32;
519                let gh = bounds.height().ceil() as u32;
520                if gw == 0 || gh == 0 {
521                    entries.insert(
522                        c,
523                        GlyphEntry {
524                            uv_min: [0.0; 2],
525                            uv_max: [0.0; 2],
526                            px_width: 0.0,
527                            px_height: 0.0,
528                            h_advance: h_adv,
529                            bearing_x: 0.0,
530                            bearing_y: 0.0,
531                        },
532                    );
533                    continue;
534                }
535                if gw > ATLAS_SIZE - 2 * pad || gh > ATLAS_SIZE - 2 * pad {
536                    return Err(anyhow!("glyph {c:?} exceeds the {ATLAS_SIZE} pixel atlas"));
537                }
538                if shelf_x + gw + pad > ATLAS_SIZE {
539                    shelf_y += shelf_h + pad;
540                    shelf_x = pad;
541                    shelf_h = 0;
542                }
543                if shelf_y + gh + pad > ATLAS_SIZE {
544                    return Err(anyhow!(
545                        "loam-text: ASCII atlas exceeded {ATLAS_SIZE}x{ATLAS_SIZE} at glyph {c:?}; \
546                         reduce bake_size_px or extend the packer"
547                    ));
548                }
549
550                let dst_x = shelf_x;
551                let dst_y = shelf_y;
552                o.draw(|gx, gy, cov| {
553                    let px_x = dst_x + gx;
554                    let px_y = dst_y + gy;
555                    if px_x < ATLAS_SIZE && px_y < ATLAS_SIZE {
556                        let idx = (px_y * ATLAS_SIZE + px_x) as usize;
557                        let v = (cov * 255.0).round().clamp(0.0, 255.0) as u8;
558                        atlas[idx] = atlas[idx].max(v);
559                    }
560                });
561
562                let uv_min = [
563                    dst_x as f32 / ATLAS_SIZE as f32,
564                    dst_y as f32 / ATLAS_SIZE as f32,
565                ];
566                let uv_max = [
567                    (dst_x + gw) as f32 / ATLAS_SIZE as f32,
568                    (dst_y + gh) as f32 / ATLAS_SIZE as f32,
569                ];
570
571                entries.insert(
572                    c,
573                    GlyphEntry {
574                        uv_min,
575                        uv_max,
576                        px_width: gw as f32,
577                        px_height: gh as f32,
578                        h_advance: h_adv,
579                        bearing_x: bounds.min.x,
580                        bearing_y: bounds.min.y,
581                    },
582                );
583
584                shelf_x += gw + pad;
585                shelf_h = shelf_h.max(gh);
586            }
587            None => {
588                entries.insert(
589                    c,
590                    GlyphEntry {
591                        uv_min: [0.0; 2],
592                        uv_max: [0.0; 2],
593                        px_width: 0.0,
594                        px_height: 0.0,
595                        h_advance: h_adv,
596                        bearing_x: 0.0,
597                        bearing_y: 0.0,
598                    },
599                );
600            }
601        }
602    }
603
604    Ok(BakedAtlas {
605        pixels: atlas,
606        glyphs: entries,
607        metrics: TextMetrics::from_font(font, bake_size_px),
608        ascent_px: scaled.ascent(),
609    })
610}
611
612const WGSL_SHADER: &str = r#"
613struct Uniforms {
614    viewport_size: vec2<f32>,
615    _pad: vec2<f32>,
616};
617
618@group(0) @binding(0) var<uniform> u: Uniforms;
619@group(0) @binding(1) var atlas_tex: texture_2d<f32>;
620@group(0) @binding(2) var atlas_sam: sampler;
621
622struct VsOut {
623    @builtin(position) clip: vec4<f32>,
624    @location(0) uv: vec2<f32>,
625    @location(1) color: vec4<f32>,
626};
627
628@vertex
629fn vs_main(
630    @location(0) in_pos: vec2<f32>,
631    @location(1) in_uv: vec2<f32>,
632    @location(2) in_color: vec4<f32>,
633) -> VsOut {
634    let ndc_x = (in_pos.x / u.viewport_size.x) * 2.0 - 1.0;
635    // pixel y axis points down; NDC y axis points up; flip.
636    let ndc_y = 1.0 - (in_pos.y / u.viewport_size.y) * 2.0;
637    var out: VsOut;
638    out.clip = vec4<f32>(ndc_x, ndc_y, 0.0, 1.0);
639    out.uv = in_uv;
640    out.color = in_color;
641    return out;
642}
643
644@fragment
645fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
646    let alpha = textureSample(atlas_tex, atlas_sam, in.uv).r;
647    return vec4<f32>(in.color.rgb, in.color.a * alpha);
648}
649"#;
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    const MOCK_ASCENT: f32 = 12.0;
656
657    fn mock_glyph_table(h_advance: f32) -> HashMap<char, GlyphEntry> {
658        (0x20u8..=0x7Eu8)
659            .map(|c| {
660                (
661                    c as char,
662                    GlyphEntry {
663                        uv_min: [0.0, 0.0],
664                        uv_max: [1.0, 1.0],
665                        bearing_x: 0.0,
666                        bearing_y: 0.0,
667                        px_width: 1.0,
668                        px_height: 1.0,
669                        h_advance,
670                    },
671                )
672            })
673            .collect()
674    }
675
676    fn mock_metrics(h_advance: f32) -> TextMetrics {
677        TextMetrics {
678            advances: (0x20u8..=0x7Eu8).map(|c| (c as char, h_advance)).collect(),
679            bake_size_px: 16.0,
680            line_height_px: 16.0,
681        }
682    }
683
684    #[test]
685    fn layout_newline_resets_x_and_advances_y() {
686        let glyphs = mock_glyph_table(10.0);
687        let mut out = Vec::new();
688        layout_text(
689            "a\nb",
690            [5.0, 0.0],
691            16.0,
692            [1.0; 4],
693            &glyphs,
694            16.0,
695            16.0,
696            MOCK_ASCENT,
697            &mut out,
698        );
699        assert_eq!(out.len(), 12);
700
701        let first = out[0];
702        assert_eq!(first.pos[0], 5.0);
703        assert!((first.pos[1] - MOCK_ASCENT).abs() < 1e-5);
704
705        let second = out[6];
706        assert_eq!(
707            second.pos[0], 5.0,
708            "newline must reset cursor_x to position[0]"
709        );
710        assert!(
711            (second.pos[1] - (MOCK_ASCENT + 16.0)).abs() < 1e-5,
712            "newline must advance cursor_y by line_h, got {}",
713            second.pos[1],
714        );
715    }
716
717    #[test]
718    fn position_y_is_the_ascender_line_at_every_scale() {
719        let mut glyphs = mock_glyph_table(10.0);
720        glyphs.insert(
721            'A',
722            GlyphEntry {
723                uv_min: [0.0, 0.0],
724                uv_max: [1.0, 1.0],
725                bearing_x: 0.0,
726                bearing_y: -MOCK_ASCENT,
727                px_width: 8.0,
728                px_height: MOCK_ASCENT,
729                h_advance: 10.0,
730            },
731        );
732        for size_px in [8.0_f32, 16.0, 40.0] {
733            let mut out = Vec::new();
734            layout_text(
735                "A",
736                [3.0, 7.0],
737                size_px,
738                [1.0; 4],
739                &glyphs,
740                16.0,
741                16.0,
742                MOCK_ASCENT,
743                &mut out,
744            );
745            assert!(
746                (out[0].pos[1] - 7.0).abs() < 1e-5,
747                "at {size_px}px the ascender landed at {} instead of 7.0",
748                out[0].pos[1]
749            );
750        }
751    }
752
753    #[test]
754    fn layout_cursor_advances_by_h_advance_scaled() {
755        let glyphs = mock_glyph_table(10.0);
756        let mut out = Vec::new();
757        layout_text(
758            "ab",
759            [0.0, 0.0],
760            32.0,
761            [1.0; 4],
762            &glyphs,
763            16.0,
764            16.0,
765            MOCK_ASCENT,
766            &mut out,
767        );
768
769        assert_eq!(out[0].pos[0], 0.0);
770        assert_eq!(out[6].pos[0], 20.0);
771    }
772
773    #[test]
774    fn layout_skips_unprintable_and_out_of_range_chars() {
775        let glyphs = mock_glyph_table(10.0);
776        let mut out = Vec::new();
777        layout_text(
778            "a\tb\u{80}c😀d",
779            [0.0, 0.0],
780            16.0,
781            [1.0; 4],
782            &glyphs,
783            16.0,
784            16.0,
785            MOCK_ASCENT,
786            &mut out,
787        );
788        assert_eq!(out.len(), 24);
789    }
790
791    #[test]
792    fn layout_skips_missing_glyphs() {
793        let mut glyphs = mock_glyph_table(10.0);
794        glyphs.remove(&'b');
795        let mut out = Vec::new();
796        layout_text(
797            "ab",
798            [0.0, 0.0],
799            16.0,
800            [1.0; 4],
801            &glyphs,
802            16.0,
803            16.0,
804            MOCK_ASCENT,
805            &mut out,
806        );
807        assert_eq!(out.len(), 6);
808    }
809
810    #[test]
811    fn metrics_keep_advance_widths_separate_from_ink_overhang() {
812        let metrics = mock_metrics(10.0);
813        let mut glyphs = mock_glyph_table(10.0);
814        let glyph = glyphs.get_mut(&'A').unwrap();
815        glyph.bearing_x = -2.0;
816        glyph.px_width = 15.0;
817        let mut vertices = Vec::new();
818        layout_text(
819            "AA\nA\n",
820            [7.0, 11.0],
821            32.0,
822            [1.0; 4],
823            &glyphs,
824            16.0,
825            16.0,
826            MOCK_ASCENT,
827            &mut vertices,
828        );
829        assert_eq!(metrics.measure("AA\nA\n", 32.0), [40.0, 96.0]);
830        assert_eq!(vertices[0].pos[0], 3.0);
831        assert_eq!(vertices[6].pos[0], 23.0);
832        assert_eq!(vertices[12].pos[0], 3.0);
833        assert_eq!(vertices[12].pos[1] - vertices[0].pos[1], 32.0);
834    }
835
836    #[test]
837    fn renderable_charset_excludes_controls_and_non_ascii() {
838        assert!(is_renderable(" ~\n"));
839        for text in ["\u{1f}", "\u{7f}", "\t", "é", "😀"] {
840            assert!(!is_renderable(text), "{text:?}");
841        }
842    }
843}