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