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(§ions, &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}