Skip to main content

xfa_layout_engine/
text.rs

1//! # Text Measurement and Wrapping
2//!
3//! Provides text wrapping for the XFA layout engine.
4//! Key functions:
5//! - `wrap_text()` — wraps text to fit within a maximum width
6//! - `measure_text()` — measures text dimensions without wrapping
7//!
8//! ## Font Metrics
9//!
10//! Text measurement uses `FontMetrics.resolved_widths` when available
11//! (populated from PDF /Widths arrays). Falls back to glyph advances
12//! from the font file via ttf_parser.
13//!
14//! ## Coordinate System
15//!
16//! All measurements are in PDF points (1pt = 1/72 inch).
17//!
18//! XFA Spec 3.3 §8.1 — Text Placement in Growable Containers (p277-279):
19//! - Growable width: text records interpreted as lines, width = longest line.
20//! - Growable height: container increases height to accommodate text.
21//! - Text split between lines only (§8.7 p291), NOT within a line.
22//! - Orphan/widow controls may restrict split points.
23//!   Not implemented yet because the layout engine does not currently track
24//!   widow/orphan state across container and page splits.
25//! - Text within rotated containers cannot be split.
26//!   Not checked yet because rotation metadata is not propagated into this
27//!   measurement layer.
28
29// Copyright (c) 2026 Innovation Trigger B.V.
30//
31// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
32// the PDFluent Commercial Licence. See the LICENSE file in this repository --
33// that file travels with the copy you received, which a URL does not.
34
35use crate::types::{Size, TextAlign};
36
37/// Font properties for text measurement.
38#[derive(Debug, Clone)]
39pub struct FontMetrics {
40    /// Font size in points.
41    pub size: f64,
42    /// Line height as a multiplier of font size (typically 1.2).
43    pub line_height: f64,
44    /// Average character width as a fraction of font size (fallback).
45    pub avg_char_width: f64,
46    /// Horizontal text alignment (from XFA `<para hAlign>`).
47    pub text_align: TextAlign,
48    /// Font family for per-character width lookup.
49    pub typeface: FontFamily,
50    /// Resolved glyph widths from the actual PDF font (units per `resolved_upem`).
51    pub resolved_widths: Option<Vec<u16>>,
52    /// Units-per-em of the resolved font (typically 1000 or 2048).
53    pub resolved_upem: Option<u16>,
54    /// Typographic ascender of the resolved font (font units).
55    pub resolved_ascender: Option<i16>,
56    /// Typographic descender of the resolved font (font units, typically negative).
57    pub resolved_descender: Option<i16>,
58}
59
60/// Font family classification for width table selection.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum FontFamily {
63    /// Times New Roman, Times, Georgia, etc.
64    Serif,
65    /// Arial, Helvetica, Verdana, Myriad Pro, etc.
66    #[default]
67    SansSerif,
68    /// Courier New, Courier, Consolas, etc.
69    Monospace,
70}
71
72impl FontFamily {
73    /// XFA Spec 3.3 §17 (p716) — Map genericFamily attribute to FontFamily.
74    pub fn from_generic_family(gf: &str) -> Self {
75        match gf {
76            "serif" | "decorative" | "cursive" => FontFamily::Serif,
77            "monospaced" => FontFamily::Monospace,
78            // sansSerif, fantasy, and unknown values default to SansSerif
79            _ => FontFamily::SansSerif,
80        }
81    }
82
83    /// Classify a typeface name into a font family.
84    pub fn from_typeface(name: &str) -> Self {
85        let lower = name.to_ascii_lowercase();
86        if lower.contains("courier") || lower.contains("consolas") || lower.contains("mono") {
87            FontFamily::Monospace
88        } else if lower.contains("times")
89            || lower.contains("georgia")
90            || lower.contains("garamond")
91            || lower.contains("palatino")
92            || lower.contains("cambria")
93            || lower.contains("book antiqua")
94            || lower.contains("century")
95            || lower.contains("serif")
96        {
97            FontFamily::Serif
98        } else {
99            // Arial, Helvetica, Verdana, Myriad Pro, Calibri, Tahoma, etc.
100            FontFamily::SansSerif
101        }
102    }
103}
104
105impl Default for FontMetrics {
106    fn default() -> Self {
107        Self {
108            // Match the engine-wide default body text size used by existing XFA forms.
109            size: 10.0,
110            // Adobe/XFA uses a 1.2x fallback line-height when font metrics are absent.
111            line_height: 1.2,
112            // Coarse fallback used only when neither resolved widths nor AFM tables apply.
113            avg_char_width: 0.50,
114            text_align: TextAlign::Left,
115            typeface: FontFamily::SansSerif,
116            resolved_widths: None,
117            resolved_upem: None,
118            resolved_ascender: None,
119            resolved_descender: None,
120        }
121    }
122}
123
124impl FontMetrics {
125    /// Create font metrics with the given size.
126    pub fn new(size: f64) -> Self {
127        Self {
128            size,
129            ..Default::default()
130        }
131    }
132
133    /// Uses resolved ascender/descender when available, else `size * 1.2`.
134    ///
135    /// XFA Spec 3.3 §28.1 (p1228) — Adobe Non-conformance: Font metrics.
136    /// Adobe's AXTE text engine ignores font-supplied line gap and uses 20% of
137    /// font height. Our approach matches: (asc−desc)/upem gives the em-square
138    /// height (no line gap added), and the fallback is size × 1.2 (20% extra).
139    pub fn line_height_pt(&self) -> f64 {
140        if let (Some(asc), Some(desc), Some(upem)) = (
141            self.resolved_ascender,
142            self.resolved_descender,
143            self.resolved_upem,
144        ) {
145            if upem > 0 {
146                return ((asc as f64) - (desc as f64)) / (upem as f64) * self.size;
147            }
148        }
149        self.size * self.line_height
150    }
151
152    /// Measure the width of `text` in points.
153    ///
154    /// When `resolved_widths` is present, widths are interpreted as per-1000
155    /// text-space units indexed directly by character code in the 0..255 range.
156    /// Upstream code must therefore apply any PDF `/FirstChar` offset as padding
157    /// before storing widths here, so that code 65 (`'A'`) is read from
158    /// `resolved_widths[65]`.
159    ///
160    /// Characters outside the available resolved-width table fall back to the
161    /// width of ASCII space. This keeps wrapping stable for partially populated
162    /// tables and avoids treating unknown characters as zero-width.
163    ///
164    /// When `resolved_widths` is absent, measurement falls back to AFM tables for
165    /// the generic serif/sans/monospace families. Non-ASCII characters in that
166    /// path use the width of `'n'` as a conservative average for one visible glyph.
167    ///
168    /// The function iterates over Unicode scalar values rather than UTF-8 bytes,
169    /// so multibyte characters contribute a single glyph width.
170    pub fn measure_width(&self, text: &str) -> f64 {
171        if let (Some(ref widths), Some(_upem)) = (&self.resolved_widths, self.resolved_upem) {
172            // Use space as the fallback width because it is usually present in
173            // PDF width tables and is safer for wrapping than a zero-width default.
174            let space_w = widths.get(b' ' as usize).copied().unwrap_or(0) as f64;
175            let mut w = 0.0;
176            for ch in text.chars() {
177                let code = ch as u32;
178                let cw = if (code as usize) < widths.len() {
179                    widths[code as usize] as f64
180                } else {
181                    space_w
182                };
183                w += cw / 1000.0 * self.size;
184            }
185            return w;
186        }
187        let table = match self.typeface {
188            FontFamily::Serif => &TIMES_WIDTHS,
189            FontFamily::SansSerif => &HELVETICA_WIDTHS,
190            FontFamily::Monospace => &COURIER_WIDTHS,
191        };
192        // The AFM fallback uses 'n' as the representative width for unsupported
193        // characters because it is a common mid-width Latin glyph.
194        let default_w = table[b'n' as usize] as f64;
195        let mut width = 0.0;
196        for ch in text.chars() {
197            let code = ch as u32;
198            let char_width = if code < 128 {
199                table[code as usize] as f64
200            } else {
201                default_w
202            };
203            width += char_width / 1000.0 * self.size;
204        }
205        width
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Per-character width tables (Adobe Font Metrics, units per 1000 em)
211// ---------------------------------------------------------------------------
212
213/// Times-Roman character widths (ASCII 0-127).
214/// Source: Adobe AFM for Times-Roman.
215#[rustfmt::skip]
216static TIMES_WIDTHS: [u16; 128] = [
217    // 0x00-0x0F: control characters → use space width (250)
218    250,250,250,250,250,250,250,250,250,250,250,250,250,250,250,250,
219    // 0x10-0x1F: control characters
220    250,250,250,250,250,250,250,250,250,250,250,250,250,250,250,250,
221    // 0x20-0x2F: SP ! " # $ % & ' ( ) * + , - . /
222    250,333,408,500,500,833,778,180,333,333,500,564,250,333,250,278,
223    // 0x30-0x3F: 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
224    500,500,500,500,500,500,500,500,500,500,278,278,564,564,564,444,
225    // 0x40-0x4F: @ A B C D E F G H I J K L M N O
226    921,722,667,667,722,611,556,722,722,333,389,722,611,889,722,722,
227    // 0x50-0x5F: P Q R S T U V W X Y Z [ \ ] ^ _
228    556,722,667,556,611,722,722,944,722,722,611,333,278,333,469,500,
229    // 0x60-0x6F: ` a b c d e f g h i j k l m n o
230    333,444,500,444,500,444,333,500,500,278,278,500,278,778,500,500,
231    // 0x70-0x7F: p q r s t u v w x y z { | } ~ DEL
232    500,500,333,389,278,500,500,722,500,500,444,480,200,480,541,250,
233];
234
235/// Helvetica character widths (ASCII 0-127).
236/// Source: Adobe AFM for Helvetica.
237#[rustfmt::skip]
238static HELVETICA_WIDTHS: [u16; 128] = [
239    // 0x00-0x0F: control characters → use space width (278)
240    278,278,278,278,278,278,278,278,278,278,278,278,278,278,278,278,
241    // 0x10-0x1F: control characters
242    278,278,278,278,278,278,278,278,278,278,278,278,278,278,278,278,
243    // 0x20-0x2F: SP ! " # $ % & ' ( ) * + , - . /
244    278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,
245    // 0x30-0x3F: 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
246    556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,
247    // 0x40-0x4F: @ A B C D E F G H I J K L M N O
248    1015,667,667,722,722,611,556,778,722,278,500,667,556,833,722,778,
249    // 0x50-0x5F: P Q R S T U V W X Y Z [ \ ] ^ _
250    667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,
251    // 0x60-0x6F: ` a b c d e f g h i j k l m n o
252    333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,
253    // 0x70-0x7F: p q r s t u v w x y z { | } ~ DEL
254    556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,278,
255];
256
257/// Courier character widths (ASCII 0-127).
258/// All characters are 600 units wide (monospace).
259#[rustfmt::skip]
260static COURIER_WIDTHS: [u16; 128] = [
261    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
262    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
263    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
264    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
265    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
266    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
267    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
268    600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,600,
269];
270
271/// Text wrapping and measurement result.
272#[derive(Debug, Clone)]
273pub struct TextLayout {
274    /// The wrapped lines of text.
275    pub lines: Vec<String>,
276    /// Per-line flag: `true` when the line is the first line of a paragraph.
277    pub first_line_of_para: Vec<bool>,
278    /// Total size of the text block.
279    pub size: Size,
280}
281
282/// Wrap text to fit within a given width, and compute the resulting size.
283///
284/// Parameters:
285/// - `text`: the source text. Explicit `\n` characters always start a new paragraph.
286/// - `max_width`: maximum available width in points for non-indented lines.
287/// - `font`: font metrics used to measure words and spaces.
288/// - `text_indent`: first-line indent in points, subtracted from the first line of
289///   each paragraph only.
290/// - `line_height_override`: optional baseline-to-baseline distance in points.
291///   When `None`, `font.line_height_pt()` is used.
292///
293/// Returns:
294/// - `TextLayout.lines`: wrapped lines in visual order.
295/// - `TextLayout.first_line_of_para`: flags indicating whether each output line is
296///   the first line of a paragraph.
297/// - `TextLayout.size`: width of the longest emitted line and total block height.
298///
299/// Edge cases:
300/// - Empty input returns no lines and a zero-size block.
301/// - Empty paragraphs caused by consecutive `\n` are preserved as blank lines.
302/// - Whitespace inside a paragraph is normalized by `split_whitespace()`, so runs
303///   of spaces do not survive wrapping.
304/// - Words are never split internally; a word wider than `max_width` is placed on
305///   its own line and may overflow horizontally.
306/// - If `text_indent >= max_width`, the first line's available width clamps to `0`
307///   so the paragraph still wraps deterministically.
308pub fn wrap_text(
309    text: &str,
310    max_width: f64,
311    font: &FontMetrics,
312    text_indent: f64,
313    line_height_override: Option<f64>,
314) -> TextLayout {
315    if text.is_empty() {
316        return TextLayout {
317            lines: vec![],
318            first_line_of_para: vec![],
319            size: Size {
320                width: 0.0,
321                height: 0.0,
322            },
323        };
324    }
325
326    let mut lines = Vec::new();
327    let mut first_line_of_para = Vec::new();
328    let mut max_line_width = 0.0_f64;
329
330    for paragraph in text.split('\n') {
331        if paragraph.is_empty() {
332            lines.push(String::new());
333            first_line_of_para.push(true);
334            continue;
335        }
336
337        let words: Vec<&str> = paragraph.split_whitespace().collect();
338        if words.is_empty() {
339            lines.push(String::new());
340            first_line_of_para.push(true);
341            continue;
342        }
343
344        let mut is_first_line = true;
345        let mut current_line = String::new();
346        let mut current_width = 0.0;
347
348        for word in words {
349            let word_width = font.measure_width(word);
350            let space_width = if current_line.is_empty() {
351                0.0
352            } else {
353                font.measure_width(" ")
354            };
355
356            let effective_max = if is_first_line {
357                (max_width - text_indent).max(0.0)
358            } else {
359                max_width
360            };
361
362            if current_width + space_width + word_width > effective_max && !current_line.is_empty()
363            {
364                // Wrap only at whitespace boundaries. The engine deliberately does
365                // not hyphenate or split within a word because XFA pagination
366                // rules operate on whole rendered lines.
367                max_line_width = max_line_width.max(current_width);
368                lines.push(current_line);
369                first_line_of_para.push(is_first_line);
370                is_first_line = false;
371                current_line = word.to_string();
372                current_width = word_width;
373            } else {
374                if !current_line.is_empty() {
375                    current_line.push(' ');
376                    current_width += space_width;
377                }
378                current_line.push_str(word);
379                current_width += word_width;
380            }
381        }
382
383        if !current_line.is_empty() {
384            max_line_width = max_line_width.max(current_width);
385            lines.push(current_line);
386            first_line_of_para.push(is_first_line);
387        }
388    }
389
390    let lh = line_height_override.unwrap_or_else(|| font.line_height_pt());
391    let height = lines.len() as f64 * lh;
392
393    TextLayout {
394        lines,
395        first_line_of_para,
396        size: Size {
397            width: max_line_width,
398            height,
399        },
400    }
401}
402
403/// Compute the bounding box of text without wrapping.
404///
405/// Each `\n` starts a new measured line, but lines are never reflowed to fit a width.
406/// Width is the maximum measured line width; height is `line_count * line_height_pt()`.
407pub fn measure_text(text: &str, font: &FontMetrics) -> Size {
408    if text.is_empty() {
409        return Size {
410            width: 0.0,
411            height: 0.0,
412        };
413    }
414
415    let lines: Vec<&str> = text.split('\n').collect();
416    let max_width = lines
417        .iter()
418        .map(|l| font.measure_width(l))
419        .fold(0.0, f64::max);
420    let height = lines.len() as f64 * font.line_height_pt();
421
422    Size {
423        width: max_width,
424        height,
425    }
426}
427
428/// Compute valid split positions (y-offsets) for multiline text.
429///
430/// XFA Spec 3.3 §8.7 (p291): text may be split between lines only.
431/// Returns the y-position of each line boundary (after line 1, after line 2, …).
432/// A text with N lines has N−1 split points.
433pub fn text_split_points(line_count: usize, line_height: f64) -> Vec<f64> {
434    if line_count <= 1 {
435        return Vec::new();
436    }
437    (1..line_count).map(|i| i as f64 * line_height).collect()
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn font_metrics_defaults() {
446        let f = FontMetrics::default();
447        assert_eq!(f.size, 10.0);
448        assert_eq!(f.line_height_pt(), 12.0); // 10 * 1.2
449    }
450
451    #[test]
452    fn measure_width_times() {
453        let f = FontMetrics {
454            size: 10.0,
455            typeface: FontFamily::Serif,
456            ..Default::default()
457        };
458        // "Hello" in Times: H=722 e=444 l=278 l=278 o=500 = 2222/1000*10 = 22.22
459        let w = f.measure_width("Hello");
460        assert!((w - 22.22).abs() < 0.01, "Times Hello={w}, expected ~22.22");
461    }
462
463    #[test]
464    fn measure_width_helvetica() {
465        let f = FontMetrics {
466            size: 10.0,
467            typeface: FontFamily::SansSerif,
468            ..Default::default()
469        };
470        // "Hello" in Helvetica: H=722 e=556 l=222 l=222 o=556 = 2278/1000*10 = 22.78
471        let w = f.measure_width("Hello");
472        assert!((w - 22.78).abs() < 0.01, "Helv Hello={w}, expected ~22.78");
473    }
474
475    #[test]
476    fn measure_width_courier() {
477        let f = FontMetrics {
478            size: 10.0,
479            typeface: FontFamily::Monospace,
480            ..Default::default()
481        };
482        // "Hello" in Courier: 5 * 600/1000*10 = 30.0
483        let w = f.measure_width("Hello");
484        assert!((w - 30.0).abs() < 0.01, "Courier Hello={w}, expected 30.0");
485    }
486
487    #[test]
488    fn times_narrower_than_old_avg() {
489        // The old avg_char_width=0.5 would give: 5*10*0.5=25.0 for "Hello"
490        // Times should be narrower (~22.22)
491        let f = FontMetrics {
492            size: 10.0,
493            typeface: FontFamily::Serif,
494            ..Default::default()
495        };
496        let w = f.measure_width("Hello");
497        assert!(w < 25.0, "Times should be narrower than old 0.5 avg");
498    }
499
500    #[test]
501    fn font_family_classification() {
502        assert_eq!(
503            FontFamily::from_typeface("Times New Roman"),
504            FontFamily::Serif
505        );
506        assert_eq!(FontFamily::from_typeface("Arial"), FontFamily::SansSerif);
507        assert_eq!(
508            FontFamily::from_typeface("Courier New"),
509            FontFamily::Monospace
510        );
511        assert_eq!(
512            FontFamily::from_typeface("Myriad Pro"),
513            FontFamily::SansSerif
514        );
515        assert_eq!(FontFamily::from_typeface("Verdana"), FontFamily::SansSerif);
516        assert_eq!(FontFamily::from_typeface("Georgia"), FontFamily::Serif);
517    }
518
519    #[test]
520    fn measure_text_single_line() {
521        let f = FontMetrics::default();
522        let s = measure_text("Hello", &f);
523        assert!(s.width > 0.0);
524        assert_eq!(s.height, 12.0);
525    }
526
527    #[test]
528    fn measure_text_multiline() {
529        let f = FontMetrics::default();
530        let s = measure_text("Line 1\nLine 2\nLine 3", &f);
531        assert_eq!(s.height, 36.0); // 3 lines * 12pt
532    }
533
534    #[test]
535    fn wrap_text_no_wrap_needed() {
536        let f = FontMetrics::default();
537        let result = wrap_text("Short", 200.0, &f, 0.0, None);
538        assert_eq!(result.lines.len(), 1);
539        assert_eq!(result.lines[0], "Short");
540    }
541
542    #[test]
543    fn wrap_text_preserves_newlines() {
544        let f = FontMetrics::default();
545        let result = wrap_text("Line 1\nLine 2", 200.0, &f, 0.0, None);
546        assert_eq!(result.lines.len(), 2);
547        assert_eq!(result.lines[0], "Line 1");
548        assert_eq!(result.lines[1], "Line 2");
549    }
550
551    #[test]
552    fn wrap_text_empty_string() {
553        let f = FontMetrics::default();
554        let result = wrap_text("", 100.0, &f, 0.0, None);
555        assert_eq!(result.lines.len(), 0);
556        assert_eq!(result.size.height, 0.0);
557    }
558
559    #[test]
560    fn resolved_widths_measure() {
561        let mut widths = vec![0u16; 256];
562        widths[b'H' as usize] = 700;
563        widths[b'i' as usize] = 300;
564        let f = FontMetrics {
565            size: 10.0,
566            resolved_widths: Some(widths),
567            resolved_upem: Some(1000),
568            ..Default::default()
569        };
570        let w = f.measure_width("Hi");
571        assert!((w - 10.0).abs() < 0.01, "resolved Hi={w}, expected 10.0");
572    }
573
574    #[test]
575    fn resolved_line_height() {
576        let f = FontMetrics {
577            size: 12.0,
578            resolved_ascender: Some(800),
579            resolved_descender: Some(-200),
580            resolved_upem: Some(1000),
581            ..Default::default()
582        };
583        let lh = f.line_height_pt();
584        assert!((lh - 12.0).abs() < 0.01, "resolved lh={lh}, expected 12.0");
585    }
586
587    #[test]
588    fn wrap_text_times_given_name() {
589        // Regression: "Given Name (First Name)" should fit in ~140pt at 9pt Times
590        let f = FontMetrics {
591            size: 9.0,
592            typeface: FontFamily::Serif,
593            ..Default::default()
594        };
595        let result = wrap_text("Given Name (First Name)", 140.0, &f, 0.0, None);
596        assert_eq!(
597            result.lines.len(),
598            1,
599            "Should fit on 1 line but got: {:?}",
600            result.lines
601        );
602    }
603
604    #[test]
605    fn resolved_widths_upem_2048() {
606        // Widths from pdf_glyph_widths() are per-1000 units regardless of upem.
607        // With upem=2048, measure_width must still divide by 1000, not 2048.
608        let mut widths = vec![0u16; 256];
609        widths[b'A' as usize] = 600; // per-1000 unit width
610        let f = FontMetrics {
611            size: 10.0,
612            resolved_widths: Some(widths),
613            resolved_upem: Some(2048),
614            ..Default::default()
615        };
616        let w = f.measure_width("A");
617        // Correct: 600/1000*10 = 6.0
618        // Old bug: 600/2048*10 = 2.93 (too narrow)
619        assert!(
620            (w - 6.0).abs() < 0.01,
621            "upem=2048: A width={w}, expected 6.0"
622        );
623    }
624
625    #[test]
626    fn multibyte_utf8_single_char_width() {
627        // 'é' (U+00E9) is 2 bytes in UTF-8 but must be measured as ONE character.
628        let mut widths = vec![0u16; 256];
629        widths[0xE9] = 500; // width of é
630        let f = FontMetrics {
631            size: 10.0,
632            resolved_widths: Some(widths),
633            resolved_upem: Some(1000),
634            ..Default::default()
635        };
636        let w = f.measure_width("\u{00E9}");
637        // Correct: 500/1000*10 = 5.0 (one char)
638        // Old bug: two bytes 0xC3+0xA9 → widths[0xC3]+widths[0xA9] = double lookup
639        assert!(
640            (w - 5.0).abs() < 0.01,
641            "é width={w}, expected 5.0 (one glyph, not two bytes)"
642        );
643    }
644
645    #[test]
646    fn afm_fallback_multibyte_char() {
647        // Non-ASCII char in AFM fallback should count as one 'n'-width, not two.
648        let f = FontMetrics {
649            size: 10.0,
650            typeface: FontFamily::SansSerif,
651            ..Default::default()
652        };
653        let w_n = f.measure_width("n");
654        let w_e_accent = f.measure_width("\u{00E9}");
655        // AFM fallback maps non-ASCII to 'n' width: one char = one 'n' width.
656        assert!(
657            (w_e_accent - w_n).abs() < 0.01,
658            "AFM fallback: é={w_e_accent} should equal n={w_n}"
659        );
660    }
661
662    #[test]
663    fn text_split_points_multi() {
664        let pts = text_split_points(4, 12.0);
665        assert_eq!(pts.len(), 3);
666        assert!((pts[0] - 12.0).abs() < 0.01);
667        assert!((pts[1] - 24.0).abs() < 0.01);
668        assert!((pts[2] - 36.0).abs() < 0.01);
669    }
670
671    #[test]
672    fn text_split_points_single_line() {
673        let pts = text_split_points(1, 12.0);
674        assert!(pts.is_empty());
675    }
676}