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