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