Skip to main content

pdfrum_doc/vt/
mod.rs

1//! The variable-text layout engine: a field value in, positioned glyphs out.
2//!
3//! A small paragraph engine. A document is a list of sections — paragraphs,
4//! split on the line breaks the text itself carries — and each section holds
5//! **one entry per character**, not per word. Line breaking then groups those
6//! entries into lines, and placement gives each one an x and a y.
7//!
8//! Two things about the coordinate space. Everything internal is y-**down**
9//! from the plate's top-left corner, and [`Layout::to_pdf`] flips it back on
10//! the way out. And the two quantities the formulas call `line_leading` and
11//! `line_indent` are **always zero** upstream — never assigned, never
12//! returned as anything else. The terms stay in the arithmetic so it reads
13//! against the source, but nothing varies them.
14//!
15//! # Who uses it
16//!
17//! The free-text and pop-up annotation generators, the `/NeedAppearances`
18//! form path, and the **widget** appearance builders in
19//! [`crate::ap::field_body`]. There is **no second implementation**: what the
20//! widget path drives is a shell over this engine whose only observable
21//! addition is a vertical alignment offset, which the builders pass as
22//! `edit_ap::generate`'s `offset`.
23
24mod autosize;
25mod bidi;
26mod classify;
27mod comb;
28pub(crate) mod edit_ap;
29pub mod hit;
30mod place;
31mod split;
32
33use kurbo::Rect;
34use std::ops::Range;
35
36pub use bidi::Direction;
37
38/// How a line sits within the plate.
39///
40/// ```
41/// use pdfrum_doc::vt::Alignment;
42///
43/// // `/Q` names the three, and the default is flush left.
44/// assert_eq!(Alignment::default(), Alignment::Left);
45/// assert_eq!(Alignment::from_quadding(1), Alignment::Center);
46/// ```
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum Alignment {
49    /// Flush left.
50    #[default]
51    Left,
52    /// Centred.
53    Center,
54    /// Flush right.
55    Right,
56}
57
58impl Alignment {
59    /// Reads a `/Q` value; anything outside `0..=2` is left-aligned.
60    ///
61    /// ```
62    /// use pdfrum_doc::vt::Alignment;
63    ///
64    /// assert_eq!(Alignment::from_quadding(0), Alignment::Left);
65    /// assert_eq!(Alignment::from_quadding(2), Alignment::Right);
66    /// // Anything outside `0..=2` is left-aligned.
67    /// assert_eq!(Alignment::from_quadding(9), Alignment::Left);
68    /// ```
69    #[must_use]
70    pub fn from_quadding(q: i64) -> Alignment {
71        match q {
72            1 => Alignment::Center,
73            2 => Alignment::Right,
74            _ => Alignment::Left,
75        }
76    }
77
78    /// The `/Q` value for this alignment — the inverse of
79    /// [`from_quadding`](Self::from_quadding) over the three it names.
80    ///
81    /// Reading is lenient where writing is not: `from_quadding` takes any
82    /// unknown number as `Left`, so the round trip holds in this direction
83    /// only.
84    ///
85    /// ```
86    /// use pdfrum_doc::vt::Alignment;
87    ///
88    /// for alignment in [Alignment::Left, Alignment::Center, Alignment::Right] {
89    ///     assert_eq!(Alignment::from_quadding(alignment.to_quadding()), alignment);
90    /// }
91    /// ```
92    #[must_use]
93    pub fn to_quadding(self) -> i64 {
94        match self {
95            Alignment::Left => 0,
96            Alignment::Center => 1,
97            Alignment::Right => 2,
98        }
99    }
100}
101
102/// The font-space scale: widths and ascents arrive per thousand units.
103pub(crate) const FONT_SCALE: f32 = 0.001;
104
105/// The sizes automatic sizing may choose from.
106///
107/// A **multi-line** field only ever considers the first quarter of these —
108/// six entries, so it can never auto-size above 12.
109pub(crate) const FONT_SIZE_STEPS: [u8; 25] = [
110    4, 6, 8, 9, 10, 12, 14, 18, 20, 25, 30, 35, 40, 45, 50, 55, 60, 70, 80, 90, 100, 110, 120, 130,
111    144,
112];
113
114/// What the engine needs to know about the font it is setting.
115///
116/// A plain record rather than a trait: there is exactly one real
117/// implementation, and the test stub is a different set of numbers rather
118/// than a different behavior.
119///
120/// ```
121/// use pdfrum_doc::geom;
122/// use pdfrum_doc::vt::{Config, Metrics, layout};
123///
124/// // Every character ten thousandths wide: the stub the layout assertions
125/// // in this crate are written against.
126/// let width = |_code: u32| 10;
127/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
128///
129/// let config = Config {
130///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
131///     font_size: 10.0,
132///     multi_line: true,
133///     ..Config::default()
134/// };
135/// let laid_out = layout("Hi", &config, &metrics);
136///
137/// // Nothing here reads a font dictionary; the numbers are the whole
138/// // contract between a caller and the engine.
139/// assert_eq!(laid_out.font_size, 10.0);
140/// ```
141#[derive(Clone, Copy)]
142pub struct Metrics<'a> {
143    /// Width per character, in thousandths of an em.
144    pub width: &'a dyn Fn(u32) -> i32,
145    /// The font's ascent, in thousandths.
146    pub ascent: i32,
147    /// The font's descent, in thousandths — normally negative.
148    pub descent: i32,
149}
150
151impl std::fmt::Debug for Metrics<'_> {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.debug_struct("Metrics")
154            .field("ascent", &self.ascent)
155            .field("descent", &self.descent)
156            .finish_non_exhaustive()
157    }
158}
159
160/// How a piece of variable text is to be set.
161///
162/// ```
163/// use pdfrum_doc::geom;
164/// use pdfrum_doc::vt::{Config, Metrics, layout};
165///
166/// // Every character ten thousandths wide: the stub the layout assertions
167/// // in this crate are written against.
168/// let width = |_code: u32| 10;
169/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
170///
171/// let config = Config {
172///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
173///     font_size: 10.0,
174///     multi_line: true,
175///     ..Config::default()
176/// };
177/// let laid_out = layout("Hi", &config, &metrics);
178///
179/// // A plain config struct with `Default` plus struct-update syntax.
180/// assert_eq!(laid_out.sections.len(), 1);
181/// ```
182#[derive(Debug, Clone, PartialEq)]
183pub struct Config {
184    /// The box the text is set into.
185    pub plate: Rect,
186    /// Where lines sit horizontally.
187    pub alignment: Alignment,
188    /// The size, or zero to choose one automatically.
189    pub font_size: f32,
190    /// Whether the text may occupy more than one line.
191    pub multi_line: bool,
192    /// Whether a line too long for the plate wraps.
193    pub auto_return: bool,
194    /// The character every character is *displayed* as, for a password field.
195    /// Widths use it too.
196    pub sub_word: Option<char>,
197    /// A cap on how many characters are set at all.
198    pub limit_char: usize,
199    /// A comb field's cell count. Non-zero switches to comb layout.
200    pub char_array: usize,
201    /// The paragraph direction offered to the bidi resolver.
202    pub direction: Direction,
203}
204
205impl Default for Config {
206    fn default() -> Self {
207        Config {
208            plate: Rect::ZERO,
209            alignment: Alignment::Left,
210            font_size: 0.0,
211            multi_line: false,
212            auto_return: false,
213            sub_word: None,
214            limit_char: 0,
215            char_array: 0,
216            direction: Direction::Auto,
217        }
218    }
219}
220
221/// One character, placed.
222///
223/// ```
224/// use pdfrum_doc::geom;
225/// use pdfrum_doc::vt::{Config, Metrics, layout};
226///
227/// // Every character ten thousandths wide: the stub the layout assertions
228/// // in this crate are written against.
229/// let width = |_code: u32| 10;
230/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
231///
232/// let config = Config {
233///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
234///     font_size: 10.0,
235///     multi_line: true,
236///     ..Config::default()
237/// };
238/// let laid_out = layout("Hi", &config, &metrics);
239///
240/// // One entry per character, not per word.
241/// let section = &laid_out.sections[0];
242/// assert_eq!(section.words.len(), 2);
243/// assert_eq!(section.words[0].ch, u32::from('H'));
244/// ```
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub struct Word {
247    /// The code point. A password field still records the real one; only its
248    /// width and its output byte come from the substitute.
249    pub ch: u32,
250    /// Position, y-down from the plate's top-left.
251    pub x: f32,
252    /// Position, y-down from the plate's top-left.
253    pub y: f32,
254    /// Extra width added after this character, used by comb cells.
255    pub tail: f32,
256    /// Whether the character sits in a right-to-left run.
257    pub is_rtl: bool,
258}
259
260/// One line of a section.
261///
262/// ```
263/// use pdfrum_doc::geom;
264/// use pdfrum_doc::vt::{Config, Metrics, layout};
265///
266/// // Every character ten thousandths wide: the stub the layout assertions
267/// // in this crate are written against.
268/// let width = |_code: u32| 10;
269/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
270///
271/// let config = Config {
272///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
273///     font_size: 10.0,
274///     multi_line: true,
275///     ..Config::default()
276/// };
277/// let laid_out = layout("Hi", &config, &metrics);
278///
279/// let line = &laid_out.sections[0].lines[0];
280/// // Half-open, over the section's own word indices.
281/// assert_eq!(line.word_range(2), 0..2);
282/// ```
283#[derive(Debug, Clone, PartialEq)]
284pub struct Line {
285    /// The words this line covers, **half-open**, or `None` for the single
286    /// line of an empty section.
287    pub words: Option<Range<u32>>,
288    /// Position, y-down.
289    pub x: f32,
290    /// Position, y-down.
291    pub y: f32,
292    /// The line's set width.
293    pub width: f32,
294    /// The tallest ascent on the line, floored at zero.
295    pub ascent: f32,
296    /// The deepest descent on the line, capped at zero.
297    pub descent: f32,
298}
299
300impl Line {
301    /// The words this line covers, as a `usize` range clamped to `available`.
302    ///
303    /// Empty for the single line of an empty section, and for a `begin` past
304    /// the section's words — a layout that shrank under an edit.
305    ///
306    /// ```
307    /// use pdfrum_doc::geom;
308    /// use pdfrum_doc::vt::{Config, Metrics, layout};
309    ///
310    /// // Every character ten thousandths wide: the stub the layout assertions
311    /// // in this crate are written against.
312    /// let width = |_code: u32| 10;
313    /// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
314    ///
315    /// let config = Config {
316    ///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
317    ///     font_size: 10.0,
318    ///     multi_line: true,
319    ///     ..Config::default()
320    /// };
321    /// let laid_out = layout("Hi", &config, &metrics);
322    ///
323    /// let line = &laid_out.sections[0].lines[0];
324    /// assert_eq!(line.word_range(2), 0..2);
325    /// // Clamped: a layout that shrank under an edit answers empty rather
326    /// // than a range past the end.
327    /// assert_eq!(line.word_range(0), 0..0);
328    /// ```
329    #[must_use]
330    pub fn word_range(&self, available: usize) -> Range<usize> {
331        let Some(words) = self.words.clone() else {
332            return 0..0;
333        };
334        let begin = usize::try_from(words.start).unwrap_or(usize::MAX);
335        let end = usize::try_from(words.end).unwrap_or(usize::MAX);
336        if begin >= available {
337            return 0..0;
338        }
339        begin..end.min(available)
340    }
341
342    /// The **last** word index the line covers, if it covers any.
343    ///
344    /// ```
345    /// use pdfrum_doc::geom;
346    /// use pdfrum_doc::vt::{Config, Metrics, layout};
347    ///
348    /// // Every character ten thousandths wide: the stub the layout assertions
349    /// // in this crate are written against.
350    /// let width = |_code: u32| 10;
351    /// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
352    ///
353    /// let config = Config {
354    ///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
355    ///     font_size: 10.0,
356    ///     multi_line: true,
357    ///     ..Config::default()
358    /// };
359    /// let laid_out = layout("Hi", &config, &metrics);
360    ///
361    /// let line = &laid_out.sections[0].lines[0];
362    /// assert_eq!(line.last_word(), Some(1));
363    /// ```
364    #[must_use]
365    pub fn last_word(&self) -> Option<u32> {
366        let words = self.words.clone()?;
367        words.end.checked_sub(1).filter(|last| *last >= words.start)
368    }
369}
370
371/// One paragraph.
372///
373/// ```
374/// use pdfrum_doc::geom;
375/// use pdfrum_doc::vt::{Config, Metrics, layout};
376///
377/// // Every character ten thousandths wide: the stub the layout assertions
378/// // in this crate are written against.
379/// let width = |_code: u32| 10;
380/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
381///
382/// let config = Config {
383///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
384///     font_size: 10.0,
385///     multi_line: true,
386///     ..Config::default()
387/// };
388/// let laid_out = layout("Hi", &config, &metrics);
389///
390/// // One paragraph, split on the line breaks the text carries.
391/// let two = layout("a\nb", &config, &metrics);
392/// assert_eq!(two.sections.len(), 2);
393/// ```
394#[derive(Debug, Clone, Default, PartialEq)]
395pub struct Section {
396    /// Its characters, in logical order.
397    pub words: Vec<Word>,
398    /// Its lines, once broken.
399    pub lines: Vec<Line>,
400    /// Its extent, y-down.
401    pub rect: Rect,
402}
403
404/// A laid-out piece of variable text.
405///
406/// ```
407/// use pdfrum_doc::geom;
408/// use pdfrum_doc::vt::{Config, Metrics, layout};
409///
410/// // Every character ten thousandths wide: the stub the layout assertions
411/// // in this crate are written against.
412/// let width = |_code: u32| 10;
413/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
414///
415/// let config = Config {
416///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
417///     font_size: 10.0,
418///     multi_line: true,
419///     ..Config::default()
420/// };
421/// let laid_out = layout("Hi", &config, &metrics);
422///
423/// assert_eq!(laid_out.sections.len(), 1);
424/// // The extent is y-down; `content_rect_pdf` flips it back.
425/// let _ = laid_out.content_rect_pdf(config.plate);
426/// ```
427#[derive(Debug, Clone, Default, PartialEq)]
428pub struct Layout {
429    /// The paragraphs.
430    pub sections: Vec<Section>,
431    /// The union of their extents, y-down.
432    pub content_rect: Rect,
433    /// The size actually used, which automatic sizing may have chosen.
434    pub font_size: f32,
435}
436
437impl Layout {
438    /// Converts an internal point to PDF's y-up space.
439    ///
440    /// ```
441    /// use pdfrum_doc::geom;
442    /// use pdfrum_doc::vt::{Config, Metrics, layout};
443    ///
444    /// // Every character ten thousandths wide: the stub the layout assertions
445    /// // in this crate are written against.
446    /// let width = |_code: u32| 10;
447    /// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
448    ///
449    /// let config = Config {
450    ///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
451    ///     font_size: 10.0,
452    ///     multi_line: true,
453    ///     ..Config::default()
454    /// };
455    /// let laid_out = layout("Hi", &config, &metrics);
456    /// use pdfrum_doc::vt::Layout;
457    ///
458    /// // Internal space is y-down from the plate's top-left corner.
459    /// assert_eq!(Layout::to_pdf(config.plate, 0.0, 0.0), (0.0, 40.0));
460    /// ```
461    #[must_use]
462    pub fn to_pdf(plate: Rect, x: f32, y: f32) -> (f32, f32) {
463        (crate::geom::left(plate) + x, crate::geom::top(plate) - y)
464    }
465
466    /// The content rectangle in PDF's y-up space.
467    ///
468    /// ```
469    /// use pdfrum_doc::geom;
470    /// use pdfrum_doc::vt::{Config, Metrics, layout};
471    ///
472    /// // Every character ten thousandths wide: the stub the layout assertions
473    /// // in this crate are written against.
474    /// let width = |_code: u32| 10;
475    /// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
476    ///
477    /// let config = Config {
478    ///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
479    ///     font_size: 10.0,
480    ///     multi_line: true,
481    ///     ..Config::default()
482    /// };
483    /// let laid_out = layout("Hi", &config, &metrics);
484    ///
485    /// let rect = laid_out.content_rect_pdf(config.plate);
486    /// // Back in PDF's y-up space, so the top edge is above the bottom.
487    /// assert!(geom::top(rect) >= geom::bottom(rect));
488    /// ```
489    #[must_use]
490    pub fn content_rect_pdf(&self, plate: Rect) -> Rect {
491        let (left, top) = Layout::to_pdf(
492            plate,
493            crate::geom::left(self.content_rect),
494            crate::geom::bottom(self.content_rect),
495        );
496        let (right, bottom) = Layout::to_pdf(
497            plate,
498            crate::geom::right(self.content_rect),
499            crate::geom::top(self.content_rect),
500        );
501        crate::geom::rect(left, bottom, right, top)
502    }
503
504    /// Every word in reading order, paired with the line it belongs to.
505    ///
506    /// ```
507    /// use pdfrum_doc::geom;
508    /// use pdfrum_doc::vt::{Config, Metrics, layout};
509    ///
510    /// // Every character ten thousandths wide: the stub the layout assertions
511    /// // in this crate are written against.
512    /// let width = |_code: u32| 10;
513    /// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
514    ///
515    /// let config = Config {
516    ///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
517    ///     font_size: 10.0,
518    ///     multi_line: true,
519    ///     ..Config::default()
520    /// };
521    /// let laid_out = layout("Hi", &config, &metrics);
522    ///
523    /// // Reading order, each character paired with its section and line.
524    /// let chars: Vec<u32> = laid_out.words().map(|(_, _, word)| word.ch).collect();
525    /// assert_eq!(chars, [u32::from('H'), u32::from('i')]);
526    /// ```
527    pub fn words(&self) -> impl Iterator<Item = (usize, usize, &Word)> {
528        self.sections.iter().enumerate().flat_map(|(s, section)| {
529            section.lines.iter().enumerate().flat_map(move |(l, line)| {
530                let range = line.word_range(section.words.len());
531                section
532                    .words
533                    .get(range)
534                    .unwrap_or_default()
535                    .iter()
536                    .map(move |word| (s, l, word))
537            })
538        })
539    }
540}
541
542/// Splits text into sections, one per line break, and characters into words.
543///
544/// Four rules the tokenizer carries:
545///
546/// - A carriage return followed by a line feed is **one** break, and so is a
547///   line feed followed by a carriage return.
548/// - In a single-line field a lone break is consumed and produces *nothing* —
549///   not even a space.
550/// - A tab becomes a space.
551/// - The character counter advances for a **break** as well as a character,
552///   so a five-character limit on `"ab\ncd"` admits `a`, `b`, the break, and
553///   `c`, and stops there.
554#[must_use]
555pub(crate) fn split_sections(text: &str, config: &Config) -> Vec<Vec<u32>> {
556    let mut sections: Vec<Vec<u32>> = vec![Vec::new()];
557    let mut count = 0usize;
558    let chars: Vec<char> = text.chars().collect();
559    let mut index = 0;
560
561    while index < chars.len() {
562        if config.limit_char > 0 && count >= config.limit_char {
563            break;
564        }
565        if config.char_array > 0 && count >= config.char_array {
566            break;
567        }
568        let ch = chars.get(index).copied().unwrap_or('\0');
569        match ch {
570            '\r' | '\n' => {
571                let partner = if ch == '\r' { '\n' } else { '\r' };
572                if chars.get(index + 1) == Some(&partner) {
573                    index += 1;
574                }
575                if config.multi_line {
576                    sections.push(Vec::new());
577                }
578            }
579            _ => {
580                let ch = if ch == '\t' { ' ' } else { ch };
581                if let Some(last) = sections.last_mut() {
582                    last.push(ch as u32);
583                }
584            }
585        }
586        count += 1;
587        index += 1;
588    }
589    sections
590}
591
592/// Lays out a piece of variable text.
593///
594/// A pure function: nothing here reads a document or a font dictionary, only
595/// the numbers [`Metrics`] supplies, which is what makes the whole engine
596/// testable against a stub.
597///
598/// ```
599/// use pdfrum_doc::geom;
600/// use pdfrum_doc::vt::{Config, Metrics, layout};
601///
602/// // Every character ten thousandths wide: the stub the layout assertions
603/// // in this crate are written against.
604/// let width = |_code: u32| 10;
605/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
606///
607/// let config = Config {
608///     plate: geom::rect(0.0, 0.0, 100.0, 40.0),
609///     font_size: 10.0,
610///     multi_line: true,
611///     ..Config::default()
612/// };
613/// let laid_out = layout("Hi", &config, &metrics);
614///
615/// assert_eq!(laid_out.sections.len(), 1);
616/// assert_eq!(laid_out.sections[0].words.len(), 2);
617/// ```
618#[must_use]
619pub fn layout(text: &str, config: &Config, metrics: &Metrics<'_>) -> Layout {
620    let mut config = config.clone();
621    let mut sections: Vec<Section> = split_sections(text, &config)
622        .into_iter()
623        .map(|words| Section {
624            words: words
625                .into_iter()
626                .map(|ch| Word {
627                    ch,
628                    x: 0.0,
629                    y: 0.0,
630                    tail: 0.0,
631                    is_rtl: false,
632                })
633                .collect(),
634            lines: Vec::new(),
635            rect: Rect::ZERO,
636        })
637        .collect();
638
639    if config.font_size == 0.0 {
640        config.font_size = autosize::auto_font_size(&sections, &config, metrics);
641    }
642
643    let content_rect = rearrange(&mut sections, &config, metrics);
644    Layout {
645        sections,
646        content_rect,
647        font_size: config.font_size,
648    }
649}
650
651/// Lays the sections out top to bottom, returning the union of their extents.
652///
653/// The union starts as the **first** section's rectangle rather than as an
654/// empty one, so a document whose first paragraph is empty still contributes
655/// that paragraph's zero-width box.
656///
657/// A section's own origin is folded into the positions it holds, on **both**
658/// axes. Placement gives each word a position relative to the section box —
659/// which is what makes the two alignment offsets partially cancel — and the
660/// box's own left edge is where the alignment that survives lives. Reading a
661/// word's position without adding it back leaves every right-aligned and
662/// centred field flush left.
663fn rearrange(sections: &mut [Section], config: &Config, metrics: &Metrics<'_>) -> Rect {
664    let mut y = 0.0;
665    let mut union: Option<Rect> = None;
666    for section in sections.iter_mut() {
667        let height = if config.char_array > 0 {
668            comb::rearrange_char_array(section, config, metrics)
669        } else {
670            section.lines.clear();
671            let measured = split::split_lines(section, config, metrics, true);
672            place::output_lines(section, config, metrics, measured)
673        };
674        section.rect = crate::geom::rect(
675            crate::geom::left(height),
676            crate::geom::bottom(height) + y,
677            crate::geom::right(height),
678            crate::geom::top(height) + y,
679        );
680        let x = crate::geom::left(height);
681        for word in &mut section.words {
682            word.x += x;
683            word.y += y;
684        }
685        for line in &mut section.lines {
686            line.x += x;
687            line.y += y;
688        }
689        y += crate::geom::height(height);
690        union = Some(match union {
691            None => section.rect,
692            Some(previous) => crate::geom::union(previous, section.rect),
693        });
694    }
695    union.unwrap_or(Rect::ZERO)
696}
697
698/// The width one character sets at a given size.
699#[must_use]
700pub(crate) fn word_width(
701    word: &Word,
702    config: &Config,
703    metrics: &Metrics<'_>,
704    font_size: f32,
705) -> f32 {
706    let shown = config.sub_word.map_or(word.ch, |sub| sub as u32);
707    #[allow(clippy::cast_precision_loss)]
708    let width = (metrics.width)(shown) as f32;
709    width * font_size * FONT_SCALE + word.tail
710}
711
712/// The ascent one character contributes at a given size.
713#[must_use]
714pub(crate) fn font_ascent(metrics: &Metrics<'_>, font_size: f32) -> f32 {
715    #[allow(clippy::cast_precision_loss)]
716    let ascent = metrics.ascent as f32;
717    ascent * font_size * FONT_SCALE
718}
719
720/// The descent one character contributes at a given size.
721#[must_use]
722pub(crate) fn font_descent(metrics: &Metrics<'_>, font_size: f32) -> f32 {
723    #[allow(clippy::cast_precision_loss)]
724    let descent = metrics.descent as f32;
725    descent * font_size * FONT_SCALE
726}
727
728#[cfg(test)]
729pub(crate) mod stub {
730    use super::Metrics;
731
732    /// Every character ten units wide, matching the upstream test provider.
733    pub(crate) fn width(_: u32) -> i32 {
734        10
735    }
736
737    /// The stub metrics the ported layout assertions are written against.
738    pub(crate) fn metrics() -> Metrics<'static> {
739        Metrics {
740            width: &width,
741            ascent: 10,
742            descent: -2,
743        }
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::{Alignment, Config, split_sections};
750
751    #[test]
752    fn a_quadding_outside_the_defined_range_is_left_aligned() {
753        assert_eq!(Alignment::from_quadding(0), Alignment::Left);
754        assert_eq!(Alignment::from_quadding(1), Alignment::Center);
755        assert_eq!(Alignment::from_quadding(2), Alignment::Right);
756        assert_eq!(Alignment::from_quadding(3), Alignment::Left);
757        assert_eq!(Alignment::from_quadding(-1), Alignment::Left);
758    }
759
760    fn multi(text: &str) -> Vec<Vec<u32>> {
761        split_sections(
762            text,
763            &Config {
764                multi_line: true,
765                ..Config::default()
766            },
767        )
768    }
769
770    #[test]
771    fn a_paired_line_break_counts_once_in_either_order() {
772        assert_eq!(multi("a\r\nb").len(), 2);
773        assert_eq!(multi("a\n\rb").len(), 2);
774        // Two separate breaks are two.
775        assert_eq!(multi("a\n\nb").len(), 3);
776    }
777
778    #[test]
779    fn a_break_in_a_single_line_field_produces_nothing_at_all() {
780        let single = split_sections("ab\ncd", &Config::default());
781        assert_eq!(single.len(), 1);
782        // Not even a space where the break was.
783        assert_eq!(single.first().map(Vec::len), Some(4));
784    }
785
786    #[test]
787    fn a_tab_becomes_a_space() {
788        let got = split_sections("a\tb", &Config::default());
789        assert_eq!(got.first().map(Vec::as_slice), Some(&[97, 32, 98][..]));
790    }
791
792    #[test]
793    fn the_character_limit_counts_line_breaks_too() {
794        let config = Config {
795            multi_line: true,
796            limit_char: 5,
797            ..Config::default()
798        };
799        // The counter is tested *before* each character and advanced after,
800        // and the break advances it too — so `ab`, the break and `cd` are
801        // five steps and all of them land. A sixth character would not.
802        let got = split_sections("ab\ncd", &config);
803        assert_eq!(got.len(), 2);
804        assert_eq!(got.first().map(Vec::len), Some(2));
805        assert_eq!(got.get(1).map(Vec::len), Some(2));
806
807        // One more character, and the limit bites.
808        let clipped = split_sections("ab\ncde", &config);
809        assert_eq!(clipped.get(1).map(Vec::len), Some(2));
810    }
811
812    #[test]
813    fn a_comb_cell_count_caps_the_characters_as_a_limit_does() {
814        let config = Config {
815            char_array: 3,
816            ..Config::default()
817        };
818        assert_eq!(
819            split_sections("abcdef", &config).first().map(Vec::len),
820            Some(3)
821        );
822    }
823}