1pub 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 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 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 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 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}