Skip to main content

oxidize_pdf/fonts/
ttf_parser.rs

1//! TrueType font parser for extracting font information
2
3use crate::error::PdfError;
4use crate::Result;
5use std::collections::HashMap;
6
7use super::cmap_utils::parse_cmap_format_12_filtered;
8use super::{FontDescriptor, FontFlags, FontMetrics};
9
10/// Character to glyph index mapping
11#[derive(Debug, Clone, Default)]
12pub struct GlyphMapping {
13    /// Map from Unicode code point to glyph index
14    char_to_glyph: HashMap<u32, u16>,
15    /// Map from glyph index to Unicode code point
16    glyph_to_char: HashMap<u16, u32>,
17    /// Glyph widths in font units
18    glyph_widths: HashMap<u16, u16>,
19    /// `true` when the real cmap could not be parsed and `char_to_glyph` holds
20    /// only a synthetic ASCII fallback. In that case glyph coverage is NOT
21    /// reliable and must not be reported as authoritative.
22    cmap_unparsed: bool,
23}
24
25impl GlyphMapping {
26    /// Get glyph index for a character
27    pub fn char_to_glyph(&self, ch: char) -> Option<u16> {
28        self.char_to_glyph.get(&(ch as u32)).copied()
29    }
30
31    /// Whether glyph coverage can be reliably determined: the table is
32    /// non-empty AND was built from the font's real cmap (not the ASCII
33    /// fallback used when cmap parsing fails).
34    pub(crate) fn coverage_known(&self) -> bool {
35        !self.char_to_glyph.is_empty() && !self.cmap_unparsed
36    }
37
38    /// Get character for a glyph index
39    pub fn glyph_to_char(&self, glyph: u16) -> Option<char> {
40        self.glyph_to_char
41            .get(&glyph)
42            .and_then(|&cp| char::from_u32(cp))
43    }
44
45    /// Add a mapping
46    pub fn add_mapping(&mut self, ch: char, glyph: u16) {
47        let code_point = ch as u32;
48        self.char_to_glyph.insert(code_point, glyph);
49        self.glyph_to_char.insert(glyph, code_point);
50    }
51
52    /// Set glyph width
53    pub fn set_glyph_width(&mut self, glyph: u16, width: u16) {
54        self.glyph_widths.insert(glyph, width);
55    }
56
57    /// Get glyph width in font units
58    pub fn get_glyph_width(&self, glyph: u16) -> Option<u16> {
59        self.glyph_widths.get(&glyph).copied()
60    }
61
62    /// Get character width in font units
63    pub fn get_char_width(&self, ch: char) -> Option<u16> {
64        self.char_to_glyph(ch)
65            .and_then(|glyph| self.get_glyph_width(glyph))
66    }
67
68    /// Iterate over all mapped characters with their widths in font units.
69    /// Yields `(char, width_in_font_units)` for each character that has both
70    /// a cmap mapping and a glyph width entry.
71    pub fn char_widths_iter(&self) -> impl Iterator<Item = (char, u16)> + '_ {
72        self.char_to_glyph
73            .iter()
74            .filter_map(move |(&code_point, &glyph_id)| {
75                let ch = char::from_u32(code_point)?;
76                let width = self.glyph_widths.get(&glyph_id).copied()?;
77                Some((ch, width))
78            })
79    }
80}
81
82/// TTF table record
83#[derive(Debug, Clone)]
84struct TableRecord {
85    #[allow(dead_code)]
86    tag: [u8; 4],
87    #[allow(dead_code)]
88    checksum: u32,
89    offset: u32,
90    length: u32,
91}
92
93/// TrueType font parser
94pub struct TtfParser<'a> {
95    data: &'a [u8],
96    tables: HashMap<String, TableRecord>,
97}
98
99impl<'a> TtfParser<'a> {
100    /// Create a new TTF parser
101    pub fn new(data: &'a [u8]) -> Result<Self> {
102        let mut parser = TtfParser {
103            data,
104            tables: HashMap::new(),
105        };
106        parser.parse_table_directory()?;
107        Ok(parser)
108    }
109
110    /// Parse the table directory
111    fn parse_table_directory(&mut self) -> Result<()> {
112        if self.data.len() < 12 {
113            return Err(PdfError::FontError("TTF header too small".into()));
114        }
115
116        // Read offset table
117        let num_tables = u16::from_be_bytes([self.data[4], self.data[5]]);
118
119        // Read table records
120        let mut offset = 12;
121        for _ in 0..num_tables {
122            if offset + 16 > self.data.len() {
123                return Err(PdfError::FontError("Invalid table directory".into()));
124            }
125
126            let tag = [
127                self.data[offset],
128                self.data[offset + 1],
129                self.data[offset + 2],
130                self.data[offset + 3],
131            ];
132            let checksum = u32::from_be_bytes([
133                self.data[offset + 4],
134                self.data[offset + 5],
135                self.data[offset + 6],
136                self.data[offset + 7],
137            ]);
138            let table_offset = u32::from_be_bytes([
139                self.data[offset + 8],
140                self.data[offset + 9],
141                self.data[offset + 10],
142                self.data[offset + 11],
143            ]);
144            let length = u32::from_be_bytes([
145                self.data[offset + 12],
146                self.data[offset + 13],
147                self.data[offset + 14],
148                self.data[offset + 15],
149            ]);
150
151            let tag_str = String::from_utf8_lossy(&tag).to_string();
152            self.tables.insert(
153                tag_str,
154                TableRecord {
155                    tag,
156                    checksum,
157                    offset: table_offset,
158                    length,
159                },
160            );
161
162            offset += 16;
163        }
164
165        Ok(())
166    }
167
168    /// Get table data by tag
169    fn get_table(&self, tag: &str) -> Option<&[u8]> {
170        self.tables.get(tag).and_then(|record| {
171            let start = record.offset as usize;
172            let end = start + record.length as usize;
173            if end <= self.data.len() {
174                Some(&self.data[start..end])
175            } else {
176                None
177            }
178        })
179    }
180
181    /// Extract font metrics from the font
182    pub fn extract_metrics(&self) -> Result<FontMetrics> {
183        // Get head table for units per em
184        let head_table = self
185            .get_table("head")
186            .ok_or_else(|| PdfError::FontError("Missing head table".into()))?;
187
188        if head_table.len() < 54 {
189            return Err(PdfError::FontError("Invalid head table".into()));
190        }
191
192        let units_per_em = u16::from_be_bytes([head_table[18], head_table[19]]);
193
194        // Get hhea table for ascent/descent
195        let hhea_table = self
196            .get_table("hhea")
197            .ok_or_else(|| PdfError::FontError("Missing hhea table".into()))?;
198
199        if hhea_table.len() < 36 {
200            return Err(PdfError::FontError("Invalid hhea table".into()));
201        }
202
203        let ascent = i16::from_be_bytes([hhea_table[4], hhea_table[5]]);
204        let descent = i16::from_be_bytes([hhea_table[6], hhea_table[7]]);
205        let line_gap = i16::from_be_bytes([hhea_table[8], hhea_table[9]]);
206
207        Ok(FontMetrics {
208            units_per_em,
209            ascent,
210            descent,
211            line_gap,
212            cap_height: ascent * 7 / 10, // Approximate
213            x_height: ascent / 2,        // Approximate
214        })
215    }
216
217    /// Create font descriptor from the font
218    pub fn create_descriptor(&self) -> Result<FontDescriptor> {
219        // Get name from name table
220        let font_name = self.extract_font_name()?;
221
222        // Get metrics for descriptor
223        let metrics = self.extract_metrics()?;
224
225        // Extract font flags
226        let flags = self.extract_font_flags()?;
227
228        // Get bounding box from head table
229        let head_table = self.get_table("head").ok_or_else(|| {
230            PdfError::InvalidFormat("Missing required 'head' table in TTF font".to_string())
231        })?;
232        let x_min = i16::from_be_bytes([head_table[36], head_table[37]]);
233        let y_min = i16::from_be_bytes([head_table[38], head_table[39]]);
234        let x_max = i16::from_be_bytes([head_table[40], head_table[41]]);
235        let y_max = i16::from_be_bytes([head_table[42], head_table[43]]);
236
237        Ok(FontDescriptor {
238            font_name: font_name.clone(),
239            font_family: font_name,
240            flags,
241            font_bbox: [x_min as f32, y_min as f32, x_max as f32, y_max as f32],
242            italic_angle: self.extract_italic_angle()?,
243            ascent: metrics.ascent as f32,
244            descent: metrics.descent as f32,
245            cap_height: metrics.cap_height as f32,
246            stem_v: 80.0,         // Default value
247            missing_width: 250.0, // Default value
248        })
249    }
250
251    /// Extract font name from name table
252    fn extract_font_name(&self) -> Result<String> {
253        let name_table = self
254            .get_table("name")
255            .ok_or_else(|| PdfError::FontError("Missing name table".into()))?;
256
257        if name_table.len() < 6 {
258            return Err(PdfError::FontError("Invalid name table".into()));
259        }
260
261        // Parse name table to extract font name (Name ID 6: PostScript name)
262        let name_data = name_table;
263
264        if name_data.len() < 6 {
265            return Ok("CustomFont".to_string());
266        }
267
268        // Read name table header
269        let num_records = u16::from_be_bytes([name_data[2], name_data[3]]);
270        let string_offset = u16::from_be_bytes([name_data[4], name_data[5]]) as usize;
271
272        if name_data.len() < 6 + (num_records as usize * 12) {
273            return Ok("CustomFont".to_string());
274        }
275
276        // Look for PostScript name (Name ID 6) or Font Family name (Name ID 1)
277        for i in 0..num_records {
278            let record_offset = 6 + (i as usize * 12);
279            if record_offset + 12 > name_data.len() {
280                break;
281            }
282
283            let platform_id =
284                u16::from_be_bytes([name_data[record_offset], name_data[record_offset + 1]]);
285            let name_id =
286                u16::from_be_bytes([name_data[record_offset + 6], name_data[record_offset + 7]]);
287            let length =
288                u16::from_be_bytes([name_data[record_offset + 8], name_data[record_offset + 9]])
289                    as usize;
290            let offset =
291                u16::from_be_bytes([name_data[record_offset + 10], name_data[record_offset + 11]])
292                    as usize;
293
294            // Look for PostScript name (ID 6) or Family name (ID 1) in platform ID 1 (Mac) or 3 (Microsoft)
295            if (name_id == 6 || name_id == 1) && (platform_id == 1 || platform_id == 3) {
296                let str_start = string_offset + offset;
297                let str_end = str_start + length;
298
299                if str_end <= name_data.len() {
300                    let name_bytes = &name_data[str_start..str_end];
301
302                    // Try to decode as UTF-8 or ASCII
303                    if platform_id == 1 {
304                        // Mac Roman encoding - simplified to ASCII
305                        let name = String::from_utf8_lossy(name_bytes).to_string();
306                        if !name.trim().is_empty() {
307                            return Ok(name.trim().to_string());
308                        }
309                    } else if platform_id == 3 {
310                        // Microsoft Unicode (UTF-16 BE)
311                        if name_bytes.len() % 2 == 0 {
312                            let utf16_chars: Vec<u16> = name_bytes
313                                .chunks_exact(2)
314                                .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
315                                .collect();
316                            if let Ok(name) = String::from_utf16(&utf16_chars) {
317                                if !name.trim().is_empty() {
318                                    return Ok(name.trim().to_string());
319                                }
320                            }
321                        }
322                    }
323                }
324            }
325        }
326
327        Ok("CustomFont".to_string())
328    }
329
330    /// Extract font flags
331    fn extract_font_flags(&self) -> Result<FontFlags> {
332        let mut flags = FontFlags::empty();
333
334        // Check if font is fixed pitch
335        if let Some(post_table) = self.get_table("post") {
336            if post_table.len() >= 12 {
337                let is_fixed_pitch = u32::from_be_bytes([
338                    post_table[8],
339                    post_table[9],
340                    post_table[10],
341                    post_table[11],
342                ]) != 0;
343                if is_fixed_pitch {
344                    flags |= FontFlags::FIXED_PITCH;
345                }
346            }
347        }
348
349        // Set symbolic flag (non-Latin fonts)
350        flags |= FontFlags::NONSYMBOLIC;
351
352        Ok(flags)
353    }
354
355    /// Extract character to glyph mapping
356    pub fn extract_glyph_mapping(&self) -> Result<GlyphMapping> {
357        let mut mapping = GlyphMapping::default();
358
359        // Get cmap table
360        let cmap_table = self
361            .get_table("cmap")
362            .ok_or_else(|| PdfError::FontError("Missing cmap table".into()))?;
363
364        if cmap_table.len() < 4 {
365            return Err(PdfError::FontError("Invalid cmap table".into()));
366        }
367
368        // Parse cmap table for character to glyph mapping
369        let cmap_data = cmap_table;
370
371        if self.parse_cmap_table(cmap_data, &mut mapping).is_err() {
372            // Fallback to basic ASCII mapping if cmap parsing fails. This is a
373            // best-effort guess, not the font's real coverage — flag it so
374            // coverage diagnostics do not treat it as authoritative.
375            mapping.cmap_unparsed = true;
376            for ch in 0x20..=0x7E {
377                mapping.add_mapping(char::from(ch), ch as u16);
378            }
379        }
380
381        // Extract glyph widths from hmtx table
382        self.extract_glyph_widths(&mut mapping)?;
383
384        Ok(mapping)
385    }
386
387    /// Extract glyph widths from hmtx table
388    fn extract_glyph_widths(&self, mapping: &mut GlyphMapping) -> Result<()> {
389        // Get hhea table for number of metrics
390        let hhea_table = self
391            .get_table("hhea")
392            .ok_or_else(|| PdfError::FontError("Missing hhea table".into()))?;
393
394        if hhea_table.len() < 36 {
395            return Err(PdfError::FontError("Invalid hhea table".into()));
396        }
397
398        let num_h_metrics = u16::from_be_bytes([hhea_table[34], hhea_table[35]]);
399
400        // Get hmtx table
401        let hmtx_table = self
402            .get_table("hmtx")
403            .ok_or_else(|| PdfError::FontError("Missing hmtx table".into()))?;
404
405        // Parse horizontal metrics
406        let mut offset = 0;
407        for glyph_id in 0..num_h_metrics {
408            if offset + 4 > hmtx_table.len() {
409                break;
410            }
411
412            let advance_width = u16::from_be_bytes([hmtx_table[offset], hmtx_table[offset + 1]]);
413            mapping.set_glyph_width(glyph_id, advance_width);
414
415            offset += 4; // advance width (2) + left side bearing (2)
416        }
417
418        // Last advance width applies to remaining glyphs
419        if num_h_metrics > 0 {
420            let last_width = mapping.get_glyph_width(num_h_metrics - 1).unwrap_or(1000);
421            // Apply to common ASCII glyphs that might be beyond num_h_metrics
422            for glyph_id in num_h_metrics..256 {
423                mapping.set_glyph_width(glyph_id, last_width);
424            }
425        }
426
427        Ok(())
428    }
429
430    /// Parse cmap table to extract character to glyph mappings
431    fn parse_cmap_table(&self, cmap_data: &[u8], mapping: &mut GlyphMapping) -> Result<()> {
432        if cmap_data.len() < 4 {
433            return Err(PdfError::FontError("Invalid cmap table header".into()));
434        }
435
436        let num_tables = u16::from_be_bytes([cmap_data[2], cmap_data[3]]) as usize;
437
438        if cmap_data.len() < 4 + num_tables * 8 {
439            return Err(PdfError::FontError("Incomplete cmap table".into()));
440        }
441
442        // Look for the best subtable (prefer Unicode platform)
443        let mut best_offset = None;
444        for i in 0..num_tables {
445            let record_offset = 4 + i * 8;
446            let platform_id =
447                u16::from_be_bytes([cmap_data[record_offset], cmap_data[record_offset + 1]]);
448            let encoding_id =
449                u16::from_be_bytes([cmap_data[record_offset + 2], cmap_data[record_offset + 3]]);
450            let subtable_offset = u32::from_be_bytes([
451                cmap_data[record_offset + 4],
452                cmap_data[record_offset + 5],
453                cmap_data[record_offset + 6],
454                cmap_data[record_offset + 7],
455            ]) as usize;
456
457            // Prefer Unicode BMP (platform 3, encoding 1/10) or Unicode platform (platform 0)
458            if (platform_id == 3 && (encoding_id == 1 || encoding_id == 10)) || platform_id == 0 {
459                best_offset = Some(subtable_offset);
460                break;
461            }
462            // Fallback to Mac Roman (platform 1, encoding 0)
463            else if platform_id == 1 && encoding_id == 0 && best_offset.is_none() {
464                best_offset = Some(subtable_offset);
465            }
466        }
467
468        if let Some(offset) = best_offset {
469            self.parse_cmap_subtable(cmap_data, offset, mapping)?;
470        }
471
472        Ok(())
473    }
474
475    /// Parse a specific cmap subtable
476    fn parse_cmap_subtable(
477        &self,
478        cmap_data: &[u8],
479        offset: usize,
480        mapping: &mut GlyphMapping,
481    ) -> Result<()> {
482        if offset + 6 > cmap_data.len() {
483            return Err(PdfError::FontError("Invalid cmap subtable offset".into()));
484        }
485
486        let format = u16::from_be_bytes([cmap_data[offset], cmap_data[offset + 1]]);
487
488        match format {
489            0 => self.parse_cmap_format_0(cmap_data, offset, mapping),
490            4 => self.parse_cmap_format_4(cmap_data, offset, mapping),
491            12 => self.parse_cmap_format_12(cmap_data, offset, mapping),
492            _ => {
493                // Unsupported format, create basic ASCII mapping
494                for ch in 0x20..=0x7E {
495                    mapping.add_mapping(char::from(ch), ch as u16);
496                }
497                Ok(())
498            }
499        }
500    }
501
502    /// Parse cmap format 0 (simple byte mapping)
503    fn parse_cmap_format_0(
504        &self,
505        cmap_data: &[u8],
506        offset: usize,
507        mapping: &mut GlyphMapping,
508    ) -> Result<()> {
509        if offset + 262 > cmap_data.len() {
510            return Err(PdfError::FontError("Incomplete cmap format 0".into()));
511        }
512
513        // Format 0: simple byte encoding, 256 bytes
514        for i in 0..256 {
515            let glyph_id = cmap_data[offset + 6 + i] as u16;
516            if glyph_id != 0 {
517                mapping.add_mapping(char::from(i as u8), glyph_id);
518            }
519        }
520
521        Ok(())
522    }
523
524    /// Parse cmap format 4 (segment mapping)
525    fn parse_cmap_format_4(
526        &self,
527        cmap_data: &[u8],
528        offset: usize,
529        mapping: &mut GlyphMapping,
530    ) -> Result<()> {
531        if offset + 14 > cmap_data.len() {
532            return Err(PdfError::FontError(
533                "Incomplete cmap format 4 header".into(),
534            ));
535        }
536
537        let seg_count_x2 = u16::from_be_bytes([cmap_data[offset + 6], cmap_data[offset + 7]]);
538        let seg_count = seg_count_x2 / 2;
539
540        let expected_length = 16 + seg_count as usize * 8;
541        if offset + expected_length > cmap_data.len() {
542            // Try to handle partial tables gracefully
543            for ch in 0x20..=0x7E {
544                mapping.add_mapping(char::from(ch), ch as u16);
545            }
546            return Ok(());
547        }
548
549        // Parse end codes
550        let end_codes_offset = offset + 14;
551        let start_codes_offset = end_codes_offset + seg_count as usize * 2 + 2; // +2 for reserved pad
552        let id_delta_offset = start_codes_offset + seg_count as usize * 2;
553        let id_range_offset_start = id_delta_offset + seg_count as usize * 2;
554
555        for i in 0..seg_count {
556            let i = i as usize;
557
558            if start_codes_offset + i * 2 + 1 >= cmap_data.len()
559                || end_codes_offset + i * 2 + 1 >= cmap_data.len()
560                || id_delta_offset + i * 2 + 1 >= cmap_data.len()
561                || id_range_offset_start + i * 2 + 1 >= cmap_data.len()
562            {
563                break;
564            }
565
566            let end_code = u16::from_be_bytes([
567                cmap_data[end_codes_offset + i * 2],
568                cmap_data[end_codes_offset + i * 2 + 1],
569            ]);
570            let start_code = u16::from_be_bytes([
571                cmap_data[start_codes_offset + i * 2],
572                cmap_data[start_codes_offset + i * 2 + 1],
573            ]);
574            let id_delta = i16::from_be_bytes([
575                cmap_data[id_delta_offset + i * 2],
576                cmap_data[id_delta_offset + i * 2 + 1],
577            ]);
578            let id_range_offset = u16::from_be_bytes([
579                cmap_data[id_range_offset_start + i * 2],
580                cmap_data[id_range_offset_start + i * 2 + 1],
581            ]) as usize;
582
583            for code in start_code..=end_code {
584                let glyph_id = if id_range_offset == 0 {
585                    // Direct mapping: apply id_delta to the character code.
586                    ((code as i32 + id_delta as i32) & 0xFFFF) as u16
587                } else {
588                    // Indirect lookup via glyphIdArray (OpenType spec §cmap Format 4).
589                    // id_range_offset is a byte offset measured from the position of
590                    // the id_range_offset entry itself.
591                    let range_offset_pos = id_range_offset_start + i * 2;
592                    let glyph_idx =
593                        range_offset_pos + id_range_offset + (code - start_code) as usize * 2;
594                    if glyph_idx + 1 >= cmap_data.len() {
595                        continue;
596                    }
597                    let raw_gid =
598                        u16::from_be_bytes([cmap_data[glyph_idx], cmap_data[glyph_idx + 1]]);
599                    if raw_gid == 0 {
600                        0
601                    } else {
602                        ((raw_gid as i32 + id_delta as i32) & 0xFFFF) as u16
603                    }
604                };
605
606                if glyph_id != 0 {
607                    if let Some(ch) = char::from_u32(code as u32) {
608                        mapping.add_mapping(ch, glyph_id);
609                    }
610                }
611            }
612        }
613
614        Ok(())
615    }
616
617    /// Parse cmap format 12: Segmented coverage (full Unicode range)
618    ///
619    /// Delegates to the shared [`parse_cmap_format_12_filtered`] implementation
620    /// (no filter — all codepoints are included) and populates `mapping`.
621    fn parse_cmap_format_12(
622        &self,
623        cmap_data: &[u8],
624        offset: usize,
625        mapping: &mut GlyphMapping,
626    ) -> Result<()> {
627        let code_to_gid = parse_cmap_format_12_filtered(cmap_data, offset, None)?;
628        for (char_code, glyph_id) in code_to_gid {
629            if let Some(ch) = char::from_u32(char_code) {
630                mapping.add_mapping(ch, glyph_id);
631            }
632        }
633        Ok(())
634    }
635
636    /// Extract italic angle from head table macStyle flags
637    fn extract_italic_angle(&self) -> Result<f32> {
638        let head_table = self
639            .get_table("head")
640            .ok_or_else(|| PdfError::FontError("Missing head table".to_string()))?;
641
642        if head_table.len() < 46 {
643            return Ok(0.0); // Default to non-italic if table is incomplete
644        }
645
646        // macStyle is at offset 44 from the start of head table
647        let mac_style = u16::from_be_bytes([head_table[44], head_table[45]]);
648
649        // Bit 1 indicates italic
650        if mac_style & 0x02 != 0 {
651            Ok(-12.0) // Common italic angle
652        } else {
653            Ok(0.0)
654        }
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn test_glyph_mapping() {
664        let mut mapping = GlyphMapping::default();
665        mapping.add_mapping('A', 65);
666        mapping.add_mapping('B', 66);
667
668        assert_eq!(mapping.char_to_glyph('A'), Some(65));
669        assert_eq!(mapping.char_to_glyph('B'), Some(66));
670        assert_eq!(mapping.char_to_glyph('C'), None);
671
672        assert_eq!(mapping.glyph_to_char(65), Some('A'));
673        assert_eq!(mapping.glyph_to_char(66), Some('B'));
674        assert_eq!(mapping.glyph_to_char(67), None);
675    }
676
677    #[test]
678    fn test_char_widths_iter_yields_all_mapped_chars() {
679        let mut m = GlyphMapping::default();
680        m.add_mapping('A', 1);
681        m.set_glyph_width(1, 700);
682        m.add_mapping('B', 2);
683        m.set_glyph_width(2, 600);
684
685        let mut pairs: Vec<(char, u16)> = m.char_widths_iter().collect();
686        pairs.sort_by_key(|&(ch, _)| ch);
687        assert_eq!(pairs, vec![('A', 700), ('B', 600)]);
688    }
689
690    #[test]
691    fn test_char_widths_iter_skips_chars_without_width() {
692        let mut m = GlyphMapping::default();
693        m.add_mapping('C', 3); // mapped but no width set
694        m.add_mapping('D', 4);
695        m.set_glyph_width(4, 500); // only D has width
696
697        let pairs: Vec<(char, u16)> = m.char_widths_iter().collect();
698        assert_eq!(pairs.len(), 1);
699        assert_eq!(pairs[0], ('D', 500));
700    }
701
702    #[test]
703    fn test_char_widths_iter_empty_on_empty_mapping() {
704        let m = GlyphMapping::default();
705        assert_eq!(m.char_widths_iter().count(), 0);
706    }
707}