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