pdfrum_doc/vt/hit.rs
1//! Mapping between a point, a caret position and a character index.
2//!
3//! Four pure queries over an already-computed [`Layout`]. Nothing here lays
4//! text out or reads a document; they are searches over the positions
5//! [`crate::vt::layout`] already assigned, which is what keeps the editor
6//! from growing a second layout engine.
7//!
8//! # What a [`Place`] is
9//!
10//! A caret sits **after** a character, and `word == -1` names the position
11//! before the first character of its line. So a line of *n* characters has
12//! *n + 1* caret positions, `-1` through `n - 1`, and the one place that is
13//! not "after some character" is the line header. An empty line has only its
14//! header.
15//!
16//! # The two coordinate spaces
17//!
18//! [`Layout`] positions are y-**down** from the plate's top-left corner.
19//! Callers work in PDF user space, y-up, so [`place_at_point`] and
20//! [`point_at_place`] take and return PDF points and flip on the way in and
21//! out — the same flip [`Layout::to_pdf`] performs, which is why they take
22//! the plate rather than assuming one.
23//!
24//! Between those two spaces sits one further shift, and it is the only thing
25//! the editor adds over the layout: a **vertical alignment offset**. A
26//! single-line field centres its one line in its plate, so the text is drawn
27//! lower than the layout placed it and a click must be lifted by the same
28//! amount before it is searched. Every query here therefore takes an
29//! `offset`, the same `(dx, dy)` `vt::edit_ap::generate` is given
30//! when the appearance is written. Passing `(0.0, 0.0)` is the top-aligned
31//! case. Getting this wrong is not a small error: a centred field's text
32//! sits *below* its layout box, so an unshifted click misses every line and
33//! falls out of the content entirely.
34//!
35//! # The tie-break, which is the whole point of this module
36//!
37//! A click lands after a character when it falls **strictly past that
38//! character's horizontal midpoint**. Strictly: a click exactly on the
39//! midpoint lands *before* the character, so a caret placed by clicking the
40//! exact centre of a glyph goes to its left. Two details make this reproduce
41//! upstream rather than merely resemble it:
42//!
43//! - The midpoint is half the character's **advance**, comb tail included —
44//! the same `vt::word_width` the layout used — not half its
45//! inked extent.
46//! - The comparison is a plain `>` on raw floats, with **no epsilon**, unlike
47//! the vertical comparisons below, which are epsilon-tolerant. That
48//! asymmetry is upstream's and is deliberate here: it decides which side of
49//! a boundary a click falls on, and softening it moves carets.
50//!
51//! The vertical searches — which section a point is in, then which line —
52//! use `geom::is_float_bigger` and its sibling, so a point within
53//! `0.0001` of a section or line edge counts as *inside* it.
54//!
55//! # A measured line's size is the layout's, not the caller's request
56//!
57//! [`Config::font_size`] is a **request**, and `0.0` is the request for
58//! automatic sizing. [`crate::vt::layout`] resolves it — picking the largest
59//! step that fits — and records the answer in [`Layout::font_size`]. That
60//! resolved value is what every later measurement reads, so the size that
61//! placed the words is the layout's, and the caller's `Config` still holds
62//! the zero it asked with.
63//!
64//! Every query over a line the layout **has** therefore measures with
65//! [`Layout::font_size`]. Measuring with the config's instead is not a
66//! rounding difference on an auto-sized field, it is **zero**: every advance
67//! collapses, so a caret lands on the leading edge of the character it should
68//! trail and every midpoint becomes the character's own origin. The visible
69//! form on `password` — a field with no `/DA` at all, so the request is zero
70//! and the layout resolves 25 — was a caret at device column 189, the fifth
71//! asterisk's *left* edge, against the oracle's 199. Fixing it moves that row
72//! from 0.991764 to 0.999016 and leaves the other 1703 byte-identical.
73//!
74//! The one place that keeps the config's size is `line_extent`'s **fallback**,
75//! for a place naming a line the layout does not have; its own doc says why,
76//! and the answer there was measured rather than reasoned.
77//!
78//! # Right-to-left runs, where every x reverses
79//!
80//! A right-to-left run is laid out with **descending** x: its first character
81//! in logical order is drawn at the run's right end. Three of this module's
82//! answers turn on that, and each of them is the opposite of its
83//! left-to-right form:
84//!
85//! - **The caret after a character** is that character's *left* edge, not its
86//! right — its origin, with no advance added. Adding the advance puts every
87//! caret in the run one character too far right.
88//! - **A line's header** — the position before the first character — is the
89//! line's *right* edge when that first character is right-to-left, because
90//! that is where it is drawn.
91//! - **A click** is resolved by a bisection over a predicate that is monotone
92//! only left to right, so down a run it answers one of the run's two
93//! logical ends rather than the character nearest the point. A scan would
94//! answer the run's beginning for every click in it, and the caret would
95//! never move.
96//!
97//! Each is transcribed at its own function below, with the reason it cannot
98//! be folded into the left-to-right case.
99//!
100//! # Out-of-content points never fail
101//!
102//! A point above every section yields the very first place; one below every
103//! section yields the very last. A point beside a line — left of its first
104//! character or right of its last — is resolved by the same midpoint rule
105//! against that line's own characters, so it clamps to the line's header or
106//! its final character rather than escaping to another line. There is no
107//! "missed" answer, which is what lets a mouse drag off the widget and keep
108//! extending a selection.
109
110// `place` and `plate` are one letter apart and both are the domain's own
111// words: a `Place` is a caret position, and the plate is the box the text is
112// set into. Renaming either to satisfy the lint would make this module read
113// against the spec that names them.
114#![allow(clippy::similar_names)]
115
116use kurbo::{Point, Rect};
117
118use crate::geom;
119use crate::vt::{Config, Layout, Metrics, Section, word_width};
120
121/// A caret position: after character `word` of line `line` of section
122/// `section`.
123///
124/// `word == None` is the line header — the position before the line's first
125/// character. Every other value names the character the caret sits after.
126///
127/// ```
128/// use pdfrum_doc::geom;
129/// use pdfrum_doc::vt::{Config, Metrics, layout};
130///
131/// // Every character ten thousandths wide: the stub the layout assertions
132/// // in this crate are written against.
133/// let width = |_code: u32| 10;
134/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
135///
136/// let config = Config {
137/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
138/// font_size: 10.0,
139/// multi_line: true,
140/// ..Config::default()
141/// };
142/// let laid_out = layout("Hi", &config, &metrics);
143/// use pdfrum_doc::vt::hit;
144/// use pdfrum_doc::vt::hit::Place;
145///
146/// // The header -- the position before a line's first character --
147/// // orders ahead of every character on it.
148/// assert!(Place::new(0, 0, None) < Place::new(0, 0, Some(0)));
149/// ```
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
151pub struct Place {
152 /// Which paragraph.
153 pub section: u32,
154 /// Which line of that paragraph.
155 pub line: u32,
156 /// The character the caret follows, or `None` for the line header.
157 ///
158 /// `None` orders before every `Some`, so the header sorts ahead of the
159 /// line's characters.
160 pub word: Option<u32>,
161}
162
163impl Place {
164 /// A place, from the three indices.
165 ///
166 /// ```
167 /// use pdfrum_doc::vt::hit::Place;
168 ///
169 /// let after_first = Place::new(0, 0, Some(0));
170 /// assert_eq!(after_first.word, Some(0));
171 /// ```
172 #[must_use]
173 pub fn new(section: u32, line: u32, word: Option<u32>) -> Place {
174 Place {
175 section,
176 line,
177 word,
178 }
179 }
180
181 /// The place every layout begins at: the header of its first line.
182 ///
183 /// A constant rather than a query, because it does not depend on the
184 /// layout — the first caret position is the first line's header whatever
185 /// the text is, including no text at all. [`begin_place`] is the same
186 /// value, spelled for symmetry with [`end_place`], which does need the
187 /// layout.
188 ///
189 /// The header is `None`, not a sentinel index.
190 ///
191 /// ```
192 /// use pdfrum_doc::vt::hit::Place;
193 ///
194 /// // The beginning of the text, not `(0, 0, 0)`: a zero `word` is the
195 /// // position *after* the first character.
196 /// assert_eq!(Place::START, Place::new(0, 0, None));
197 /// assert_eq!(Place::default(), Place::START);
198 /// ```
199 pub const START: Place = Place {
200 section: 0,
201 line: 0,
202 word: None,
203 };
204
205 /// [`Place::START`], as a function.
206 ///
207 /// ```
208 /// use pdfrum_doc::vt::hit::Place;
209 ///
210 /// assert_eq!(Place::start(), Place::START);
211 /// ```
212 #[must_use]
213 pub fn start() -> Place {
214 Place::START
215 }
216}
217
218/// The beginning of the text, not `(0, 0, 0)`.
219///
220/// `#[derive(Default)]` would zero all three fields, and a zero `word` is the
221/// position *after* the first character — a different, valid-looking caret
222/// that no layout ever means by "the default". The beginning of the text is
223/// section 0, line 0, and **no** word, which is what a caller reaching for a
224/// default wants.
225impl Default for Place {
226 fn default() -> Place {
227 Place::START
228 }
229}
230
231/// The first place in a layout: the header of its first line.
232///
233/// ```
234/// use pdfrum_doc::geom;
235/// use pdfrum_doc::vt::{Config, Metrics, layout};
236///
237/// // Every character ten thousandths wide: the stub the layout assertions
238/// // in this crate are written against.
239/// let width = |_code: u32| 10;
240/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
241///
242/// let config = Config {
243/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
244/// font_size: 10.0,
245/// multi_line: true,
246/// ..Config::default()
247/// };
248/// let laid_out = layout("Hi", &config, &metrics);
249/// use pdfrum_doc::vt::hit;
250/// use pdfrum_doc::vt::hit::Place;
251///
252/// assert_eq!(hit::begin_place(&laid_out), Place::START);
253/// ```
254#[must_use]
255pub fn begin_place(layout: &Layout) -> Place {
256 let _ = layout;
257 Place::start()
258}
259
260/// The last place in a layout: after the last character of its last line.
261///
262/// An empty layout, and one whose last section has no lines, both answer the
263/// header of section zero — there is nowhere else for a caret to be.
264///
265/// ```
266/// use pdfrum_doc::geom;
267/// use pdfrum_doc::vt::{Config, Metrics, layout};
268///
269/// // Every character ten thousandths wide: the stub the layout assertions
270/// // in this crate are written against.
271/// let width = |_code: u32| 10;
272/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
273///
274/// let config = Config {
275/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
276/// font_size: 10.0,
277/// multi_line: true,
278/// ..Config::default()
279/// };
280/// let laid_out = layout("Hi", &config, &metrics);
281/// use pdfrum_doc::vt::hit;
282/// use pdfrum_doc::vt::hit::Place;
283///
284/// // After the last character of the last line.
285/// assert_eq!(hit::end_place(&laid_out), Place::new(0, 0, Some(1)));
286/// // An empty layout answers the header of section zero.
287/// assert_eq!(hit::end_place(&Default::default()), Place::new(0, 0, None));
288/// ```
289#[must_use]
290pub fn end_place(layout: &Layout) -> Place {
291 let Some(section) = layout.sections.len().checked_sub(1) else {
292 return Place::new(0, 0, None);
293 };
294 let Some(last) = layout.sections.get(section) else {
295 return Place::new(0, 0, None);
296 };
297 end_of_section(last, section)
298}
299
300/// The last place of one section.
301fn end_of_section(section: &Section, index: usize) -> Place {
302 let Some(line_index) = section.lines.len().checked_sub(1) else {
303 return Place::new(clamp_index(index), 0, None);
304 };
305 let word = section
306 .lines
307 .get(line_index)
308 .and_then(crate::vt::Line::last_word);
309 Place::new(clamp_index(index), clamp_index(line_index), word)
310}
311
312/// A `usize` index as the `u32` a [`Place`] holds, saturating rather than
313/// wrapping on a layout too large to index — such a layout cannot be produced
314/// by this crate, and saturating keeps the query total.
315fn clamp_index(index: usize) -> u32 {
316 u32::try_from(index).unwrap_or(u32::MAX)
317}
318
319/// The caret position a click at `point` selects.
320///
321/// `point` is in PDF user space (y-up); `plate` is the box the layout was set
322/// into, the same one [`Config::plate`] carried. The result is always a valid
323/// place — see the module docs on out-of-content points.
324///
325/// # The rule
326///
327/// Within a line, the caret lands after the last character whose horizontal
328/// midpoint the point is **strictly** past. A point exactly on a midpoint is
329/// therefore *before* that character, not after it.
330///
331/// ```
332/// use pdfrum_doc::geom;
333/// use pdfrum_doc::vt::{Config, Metrics, layout};
334///
335/// // Every character ten thousandths wide: the stub the layout assertions
336/// // in this crate are written against.
337/// let width = |_code: u32| 10;
338/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
339///
340/// let config = Config {
341/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
342/// font_size: 10.0,
343/// multi_line: true,
344/// ..Config::default()
345/// };
346/// let laid_out = layout("Hi", &config, &metrics);
347/// use pdfrum_doc::vt::hit;
348///
349/// // A point left of the first character lands on the line header.
350/// let point = kurbo::Point::new(-10.0, 35.0);
351/// let place = hit::place_at_point(
352/// &laid_out, config.plate, &config, &metrics, (0.0, 0.0), point);
353/// assert_eq!(place.word, None);
354/// ```
355#[must_use]
356pub fn place_at_point(
357 layout: &Layout,
358 plate: Rect,
359 config: &Config,
360 metrics: &Metrics<'_>,
361 offset: (f32, f32),
362 point: Point,
363) -> Place {
364 // Into layout space: undo the alignment offset the text was drawn with,
365 // then x from the plate's left edge and y downward from its top. The
366 // same transform `point_at_place` inverts.
367 #[allow(clippy::cast_possible_truncation)]
368 let x = point.x as f32 - offset.0 - geom::left(plate);
369 #[allow(clippy::cast_possible_truncation)]
370 let y = geom::top(plate) - (point.y as f32 - offset.1);
371
372 match find_section(layout, y) {
373 Found::Inside(index, section) => {
374 let mut place = place_in_section(section, config, metrics, layout.font_size, x, y);
375 place.section = clamp_index(index);
376 place
377 }
378 Found::Above => begin_place(layout),
379 Found::Below => end_place(layout),
380 }
381}
382
383/// Which section a y falls in, or which way it fell off.
384enum Found<'a> {
385 /// Inside this section, at this index.
386 Inside(usize, &'a Section),
387 /// Above every section.
388 Above,
389 /// Below every section, or there are none.
390 Below,
391}
392
393/// Locates the section a layout-space y sits in.
394///
395/// Sections are laid out top to bottom and never overlap, so this is a scan
396/// for the first whose box contains the point, with the two off-the-end
397/// answers distinguished. A point in the seam between two sections — which
398/// the epsilon makes possible — belongs to the first, because the scan stops
399/// at the first match.
400fn find_section(layout: &Layout, y: f32) -> Found<'_> {
401 let mut any_above = false;
402 for (index, section) in layout.sections.iter().enumerate() {
403 let (top, bottom) = (geom::bottom(section.rect), geom::top(section.rect));
404 if geom::is_float_smaller(y, top) {
405 // Above this section. Since sections only descend from here, the
406 // point is above everything unless an earlier one already claimed
407 // to be above the point.
408 return if any_above {
409 Found::Below
410 } else {
411 Found::Above
412 };
413 }
414 if geom::is_float_bigger(y, bottom) {
415 any_above = true;
416 continue;
417 }
418 return Found::Inside(index, section);
419 }
420 Found::Below
421}
422
423/// The place a point selects within one section, in layout space.
424///
425/// `font_size` is the **layout's** resolved size, not the config's request —
426/// see the module docs.
427fn place_in_section(
428 section: &Section,
429 config: &Config,
430 metrics: &Metrics<'_>,
431 font_size: f32,
432 x: f32,
433 y: f32,
434) -> Place {
435 let Some((index, line)) = find_line(section, y) else {
436 // Above or below every line of a section the point is nonetheless
437 // inside. Falling to the section's own ends keeps the answer a valid
438 // place; an empty section has only its header.
439 return if section.lines.is_empty() {
440 Place::new(0, 0, None)
441 } else {
442 end_of_section(section, 0)
443 };
444 };
445 Place::new(
446 0,
447 clamp_index(index),
448 word_at_x(section, line_range(line), config, metrics, font_size, x),
449 )
450}
451
452/// Locates the line a section-space y sits in.
453///
454/// A y above the first line answers the first line, and one below the last
455/// answers the last — clicking in a field's top or bottom margin puts the
456/// caret on the nearest line rather than nowhere.
457fn find_line(section: &Section, y: f32) -> Option<(usize, &crate::vt::Line)> {
458 let mut last: Option<(usize, &crate::vt::Line)> = None;
459 for (index, line) in section.lines.iter().enumerate() {
460 // A line's box runs from its baseline less its ascent to its baseline
461 // less its descent — descent being negative, so the bottom is below.
462 let top = line.y - line.ascent;
463 let bottom = line.y - line.descent;
464 if geom::is_float_smaller(y, top) {
465 // Above this line. The first line claims a point above the whole
466 // section; otherwise the point sits in the gap above this one and
467 // the line before it is the nearer.
468 return Some(last.unwrap_or((index, line)));
469 }
470 if geom::is_float_bigger(y, bottom) {
471 last = Some((index, line));
472 continue;
473 }
474 return Some((index, line));
475 }
476 last
477}
478
479/// The half-open range of word indices one line covers.
480///
481/// A line whose `words` is `None` is the single line of an empty section and
482/// covers nothing.
483fn line_range(line: &crate::vt::Line) -> std::ops::Range<usize> {
484 let Some(words) = line.words.clone() else {
485 return 0..0;
486 };
487 let begin = usize::try_from(words.start).unwrap_or(usize::MAX);
488 let end = usize::try_from(words.end).unwrap_or(usize::MAX);
489 begin..end.max(begin)
490}
491
492/// Which character of a line's range a layout-space x lands after.
493///
494/// Returns `None` for the line header. This is the tie-break the module docs
495/// describe: strictly past a character's midpoint puts the caret after it.
496///
497/// # Why this bisects rather than scans
498///
499/// The predicate — past the midpoint — is monotone along a left-to-right
500/// line, and a scan and a bisection agree on every monotone input. A
501/// right-to-left run is **not** monotone: the layout gives its characters
502/// descending x, so the first character's midpoint is the rightmost one on
503/// the line and a scan stops at it, answering the header for every click left
504/// of it. Upstream bisects anyway and takes whatever the narrowing lands on,
505/// which for a descending run resolves toward the run's *last* logical
506/// character — the one drawn at the line's left edge — and so puts the caret
507/// at the end of the run rather than at its beginning.
508///
509/// That is not an incidental difference. A click in the left half of a Hebrew
510/// word is the common case, and the two answers name opposite ends of it. So
511/// the bisection is transcribed rather than simplified, including the three
512/// details that decide where it lands: `nMid` is recomputed from the halved
513/// interval each step, the two `nMid == nLeft` / `nMid == nRight` guards are
514/// what terminate it, and **the answer is the post-loop test at `nMid`
515/// alone** — not the running maximum a scan would keep.
516fn word_at_x(
517 section: &Section,
518 range: std::ops::Range<usize>,
519 config: &Config,
520 metrics: &Metrics<'_>,
521 font_size: f32,
522 x: f32,
523) -> Option<u32> {
524 // Raw `>`, no epsilon: the boundary belongs to the character's left half,
525 // so a click exactly on a midpoint lands before that character.
526 //
527 // `font_size` is the layout's, so an auto-sized field's midpoints are the
528 // real ones. Measured with the config's request instead, every advance is
529 // zero and every midpoint collapses onto the character's own origin —
530 // which makes the bisection answer by position alone and puts the caret
531 // one character early everywhere in the line.
532 let past_midpoint = |index: usize| {
533 section
534 .words
535 .get(index)
536 .is_some_and(|word| x > word.x + word_width(word, config, metrics, font_size) * 0.5)
537 };
538
539 if range.is_empty() {
540 return None;
541 }
542 let (mut left, mut right) = (range.start, range.end);
543 let mut mid = left.saturating_add(right) / 2;
544 while left < right {
545 if mid == left {
546 break;
547 }
548 if mid == right {
549 mid = mid.saturating_sub(1);
550 break;
551 }
552 if section.words.get(mid).is_none() {
553 break;
554 }
555 if past_midpoint(mid) {
556 left = mid;
557 } else {
558 right = mid;
559 }
560 mid = left.saturating_add(right) / 2;
561 }
562 if past_midpoint(mid) {
563 // A `mid` too large to be a `u32` cannot be produced by this crate,
564 // and saturating keeps the query total. `Option` keeps an
565 // unrepresentable index apart from "before the first character".
566 return Some(u32::try_from(mid).unwrap_or(u32::MAX));
567 }
568 None
569}
570
571/// Where a caret at `place` is drawn, in PDF user space.
572///
573/// The returned point is the caret's **top**: `x` is the trailing edge of the
574/// character the place names (its leading edge, for a line header), and `y`
575/// is the line's ascent line. [`caret_rect`] turns that into the rectangle
576/// [`crate::ap::field_body::Highlight`] wants.
577///
578/// A place naming a section, line or character the layout does not have
579/// answers that container's own start rather than failing — the layout can
580/// shrink under an edit while a caret still points into it, and a caret
581/// outside the text is not an error.
582///
583/// ```
584/// use pdfrum_doc::geom;
585/// use pdfrum_doc::vt::{Config, Metrics, layout};
586///
587/// // Every character ten thousandths wide: the stub the layout assertions
588/// // in this crate are written against.
589/// let width = |_code: u32| 10;
590/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
591///
592/// let config = Config {
593/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
594/// font_size: 10.0,
595/// multi_line: true,
596/// ..Config::default()
597/// };
598/// let laid_out = layout("Hi", &config, &metrics);
599/// use pdfrum_doc::vt::hit;
600/// use pdfrum_doc::vt::hit::Place;
601///
602/// // The inverse of `place_at_point`, in PDF user space.
603/// let point = hit::point_at_place(
604/// &laid_out, config.plate, &config, &metrics, (0.0, 0.0), Place::START);
605/// assert_eq!(point.x, f64::from(geom::left(config.plate)));
606/// ```
607#[must_use]
608pub fn point_at_place(
609 layout: &Layout,
610 plate: Rect,
611 config: &Config,
612 metrics: &Metrics<'_>,
613 offset: (f32, f32),
614 place: Place,
615) -> Point {
616 let (x, y) = caret_position(layout, config, metrics, place);
617 let (x, y) = Layout::to_pdf(plate, x, y);
618 Point::new(f64::from(x + offset.0), f64::from(y + offset.1))
619}
620
621/// The caret's rectangle: a hairline from the line's ascent to its descent.
622///
623/// `width` is the caret's thickness — [`crate::ap::field_body::CARET_WIDTH`]
624/// for a drawn caret. The rectangle is in PDF user space, ready to hand to
625/// [`crate::ap::field_body::Highlight`].
626///
627/// ```
628/// use pdfrum_doc::geom;
629/// use pdfrum_doc::vt::{Config, Metrics, layout};
630///
631/// // Every character ten thousandths wide: the stub the layout assertions
632/// // in this crate are written against.
633/// let width = |_code: u32| 10;
634/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
635///
636/// let config = Config {
637/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
638/// font_size: 10.0,
639/// multi_line: true,
640/// ..Config::default()
641/// };
642/// let laid_out = layout("Hi", &config, &metrics);
643/// use pdfrum_doc::vt::hit;
644/// use pdfrum_doc::vt::hit::Place;
645///
646/// let rect = hit::caret_rect(
647/// &laid_out, config.plate, &config, &metrics, (0.0, 0.0), Place::START, 1.0);
648/// // A hairline from the line's ascent down to its descent.
649/// assert_eq!(geom::width(rect), 1.0);
650/// assert!(geom::height(rect) > 0.0);
651/// ```
652#[must_use]
653pub fn caret_rect(
654 layout: &Layout,
655 plate: Rect,
656 config: &Config,
657 metrics: &Metrics<'_>,
658 offset: (f32, f32),
659 place: Place,
660 width: f32,
661) -> Rect {
662 let (x, top) = caret_position(layout, config, metrics, place);
663 let height = line_extent(layout, config, metrics, place);
664 let (left, top) = Layout::to_pdf(plate, x, top);
665 let (left, top) = (left + offset.0, top + offset.1);
666 geom::rect(left, top - height, left + width, top)
667}
668
669/// The caret's x and its line's ascent line, both in layout space.
670fn caret_position(
671 layout: &Layout,
672 config: &Config,
673 metrics: &Metrics<'_>,
674 place: Place,
675) -> (f32, f32) {
676 let Some(section) = layout.sections.get(place.section as usize) else {
677 return (0.0, 0.0);
678 };
679 let Some(line) = section.lines.get(place.line as usize) else {
680 return (0.0, 0.0);
681 };
682 let top = line.y - line.ascent;
683 let Some(word_index) = place.word else {
684 return (line_caret_x(section, line), top);
685 };
686 let index = usize::try_from(word_index).unwrap_or(usize::MAX);
687 let Some(word) = section.words.get(index) else {
688 return (line_caret_x(section, line), top);
689 };
690 (caret_x(word, config, metrics, layout.font_size), top)
691}
692
693/// The x a caret sitting **after** one character is drawn at.
694///
695/// A left-to-right character's trailing edge is its right one, so the caret is
696/// the character's origin plus its advance. A right-to-left character advances
697/// the other way — its origin is already the *right* end of the cell the layout
698/// gave it, and the caret after it belongs on its **left**, which is the
699/// origin itself. Adding the advance to both is the same one-advance error
700/// everywhere it happens: every caret in a Hebrew or Arabic run lands one
701/// character to the right of the gap it names.
702///
703/// The advance is measured at the **layout's** resolved `font_size`. At the
704/// config's request it would be zero on an auto-sized field, which collapses
705/// the left-to-right branch onto the right-to-left one: every caret would sit
706/// on its character's leading edge, one advance short of the gap it names.
707fn caret_x(word: &crate::vt::Word, config: &Config, metrics: &Metrics<'_>, font_size: f32) -> f32 {
708 if word.is_rtl {
709 word.x
710 } else {
711 word.x + word_width(word, config, metrics, font_size)
712 }
713}
714
715/// The x a caret sitting at a line's **header** — before its first character —
716/// is drawn at.
717///
718/// The header is the position before the first character *in logical order*,
719/// and in a right-to-left line that character is drawn at the line's right
720/// end. So the answer is the line's trailing edge, not its leading one: a
721/// left-to-right line answers its left edge and a right-to-left line answers
722/// `x + width`. An empty line has no first character and keeps the left edge,
723/// which is what puts an empty right-aligned field's caret where its
724/// alignment already put the line.
725///
726/// The direction comes from the **first word alone**, not from the line's
727/// dominant direction: a line whose first run is right-to-left and whose
728/// second is not still answers its right edge.
729fn line_caret_x(section: &Section, line: &crate::vt::Line) -> f32 {
730 let Some(words) = line.words.clone() else {
731 return line.x;
732 };
733 // Only a word this line actually owns decides: a `start` past the
734 // section's words is a layout that shrank under an edit, and it answers
735 // the left edge rather than reading a neighbour's direction. An empty
736 // range owns nothing.
737 let first = (words.start < words.end)
738 .then(|| usize::try_from(words.start).ok())
739 .flatten()
740 .and_then(|index| section.words.get(index));
741 if first.is_some_and(|word| word.is_rtl) {
742 line.x + line.width
743 } else {
744 line.x
745 }
746}
747
748/// The height of the line a place sits on.
749///
750/// # The fallback deliberately keeps the **config's** size
751///
752/// It is reached only for a place naming a line the layout does not have, and
753/// the two sizes then say different things. `Layout::font_size` is what the
754/// resolved layout used, and on an auto-sized field it answers a height for a
755/// line that does not exist; the config's is what the *caller* asked for, and
756/// a caller that asked for automatic sizing gets nothing. That second answer
757/// is upstream's: `CPWL_EditImpl::GetCaretRect` reads a word range that is
758/// empty for a missing place, so the caret it hands back has no height.
759///
760/// This was measured, not reasoned. Substituting the layout's size here moves
761/// **170 rows, 37 of them downward** — every widget whose caret rect is asked
762/// for a line it does not have gains a height it did not have before, and the
763/// checkbox and radio families lose by it. The upward move on `password` is
764/// +0.000006 of the +0.007252 this module's other two size fixes are worth.
765/// So this one stays as it is; the two that matter are `caret_x` and
766/// `word_at_x`, and both take the layout's size for a line that does exist.
767fn line_extent(layout: &Layout, config: &Config, metrics: &Metrics<'_>, place: Place) -> f32 {
768 let fallback = || {
769 crate::vt::font_ascent(metrics, config.font_size)
770 - crate::vt::font_descent(metrics, config.font_size)
771 };
772 layout
773 .sections
774 .get(place.section as usize)
775 .and_then(|section| section.lines.get(place.line as usize))
776 .map_or_else(fallback, |line| line.ascent - line.descent)
777}
778
779/// How many characters precede `place` in the layout's text.
780///
781/// Counting the way the text itself does: every character of every earlier
782/// section, **plus one for each section break**, plus the characters of this
783/// place's own line up to and including the one it names. So the index of the
784/// caret before the first character is zero, and the index after the last is
785/// the text's length.
786///
787/// # A wrapped line's header is not a position of its own
788///
789/// `place_at_point` names the *line header* — a place with no word — for a
790/// click left of every midpoint on a line, and on a wrapped line that header
791/// is the same caret position as the end of the line above it. The two are
792/// folded together before anything is counted: a header on any line but the
793/// first becomes the place of the character preceding it. So the header of
794/// line 1 of a section whose first line holds characters `0..=4` counts as
795/// **5**, not 0. Skipping the fold is not a rounding difference: it sends a
796/// caret clicked at the start of a wrapped line to the start of the whole
797/// field, and the next keystroke lands there.
798///
799/// # A place past the layout
800///
801/// Clamped to the end, and the clamp counts **no trailing section break**, so
802/// an out-of-range section index yields the last section's end rather than
803/// one past it.
804///
805/// ```
806/// use pdfrum_doc::geom;
807/// use pdfrum_doc::vt::{Config, Metrics, layout};
808///
809/// // Every character ten thousandths wide: the stub the layout assertions
810/// // in this crate are written against.
811/// let width = |_code: u32| 10;
812/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
813///
814/// let config = Config {
815/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
816/// font_size: 10.0,
817/// multi_line: true,
818/// ..Config::default()
819/// };
820/// let laid_out = layout("Hi", &config, &metrics);
821/// use pdfrum_doc::vt::hit;
822/// use pdfrum_doc::vt::hit::Place;
823///
824/// assert_eq!(hit::word_index_of_place(&laid_out, Place::START), 0);
825/// assert_eq!(hit::word_index_of_place(&laid_out, Place::new(0, 0, Some(1))), 2);
826/// ```
827#[must_use]
828pub fn word_index_of_place(layout: &Layout, place: Place) -> usize {
829 // The fold, before anything is counted. A header on the first line has no
830 // line above it and stays where it is.
831 if place.word.is_none() && place.line > 0 {
832 // The character before this line's first is the last of the line
833 // above -- which is where a header on any line but the first folds to.
834 let previous = layout
835 .sections
836 .get(place.section as usize)
837 .and_then(|section| section.lines.get(place.line as usize))
838 .map(|line| {
839 let before = line
840 .words
841 .as_ref()
842 .and_then(|words| words.start.checked_sub(1));
843 Place::new(place.section, place.line - 1, before)
844 });
845 if let Some(previous) = previous {
846 return word_index_of_place(layout, previous);
847 }
848 }
849
850 let target = place.section as usize;
851 let last = layout.sections.len().saturating_sub(1);
852 let mut index: usize = 0;
853 for (position, section) in layout.sections.iter().enumerate() {
854 if position >= target {
855 break;
856 }
857 index = index.saturating_add(section.words.len());
858 // The break between this section and the next counts as one
859 // character, exactly as the tokenizer counted it on the way in — and
860 // there is no break after the last section to count.
861 if position != last {
862 index = index.saturating_add(SECTION_BREAK_LENGTH);
863 }
864 }
865 let Some(section) = layout.sections.get(target) else {
866 return index;
867 };
868 // The header is "before character 0", so it consumes none of this
869 // section; character `w` consumes `w + 1`.
870 let after = place.word.map_or(0, |word| {
871 usize::try_from(word)
872 .unwrap_or(usize::MAX)
873 .saturating_add(1)
874 });
875 index.saturating_add(after.min(section.words.len()))
876}
877
878/// A section break counts as one character in the flat index, the way the
879/// line break it came from did.
880const SECTION_BREAK_LENGTH: usize = 1;
881
882/// Which caret position sits after `index` characters of the layout's text.
883///
884/// The inverse of [`word_index_of_place`]: for every place a layout can
885/// produce, `place_of_word_index(layout, word_index_of_place(layout, p)) ==
886/// p`. An index past the end answers the last place.
887///
888/// An index landing exactly *on* a section break — the position between a
889/// paragraph's last character and the next paragraph's first — answers the
890/// **end of the earlier** section, which is where a caret typed to that point
891/// actually sits.
892///
893/// ```
894/// use pdfrum_doc::geom;
895/// use pdfrum_doc::vt::{Config, Metrics, layout};
896///
897/// // Every character ten thousandths wide: the stub the layout assertions
898/// // in this crate are written against.
899/// let width = |_code: u32| 10;
900/// let metrics = Metrics { width: &width, ascent: 1000, descent: -200 };
901///
902/// let config = Config {
903/// plate: geom::rect(0.0, 0.0, 100.0, 40.0),
904/// font_size: 10.0,
905/// multi_line: true,
906/// ..Config::default()
907/// };
908/// let laid_out = layout("Hi", &config, &metrics);
909/// use pdfrum_doc::vt::hit;
910/// use pdfrum_doc::vt::hit::Place;
911///
912/// // The inverse of `word_index_of_place`.
913/// assert_eq!(hit::place_of_word_index(&laid_out, 0), Place::START);
914/// assert_eq!(hit::place_of_word_index(&laid_out, 2), Place::new(0, 0, Some(1)));
915/// ```
916#[must_use]
917pub fn place_of_word_index(layout: &Layout, index: usize) -> Place {
918 let mut consumed: usize = 0;
919 for (position, section) in layout.sections.iter().enumerate() {
920 let end = consumed.saturating_add(section.words.len());
921 if index <= end {
922 let within = index.saturating_sub(consumed);
923 // Index 0 within a section is its header; index `n` is character
924 // `n - 1`.
925 let word = within
926 .checked_sub(1)
927 .map(|w| u32::try_from(w).unwrap_or(u32::MAX));
928 return place_of_word(section, position, word);
929 }
930 consumed = end.saturating_add(SECTION_BREAK_LENGTH);
931 }
932 end_place(layout)
933}
934
935/// The place naming character `word` of a section, with the line it falls on
936/// resolved.
937fn place_of_word(section: &Section, index: usize, word: Option<u32>) -> Place {
938 let line = line_of_word(section, word);
939 Place::new(clamp_index(index), line, word)
940}
941
942/// Which line of a section a character index falls on.
943///
944/// A `None` word is the first line's header. A character past the section's
945/// last line answers that last line, which keeps the place valid.
946fn line_of_word(section: &Section, word: Option<u32>) -> u32 {
947 let Some(word) = word else {
948 return 0;
949 };
950 for (index, line) in section.lines.iter().enumerate() {
951 if line
952 .words
953 .as_ref()
954 .is_some_and(|words| words.contains(&word))
955 {
956 return clamp_index(index);
957 }
958 }
959 clamp_index(section.lines.len().saturating_sub(1))
960}
961
962#[cfg(test)]
963mod tests {
964 use super::{
965 Place, begin_place, caret_rect, end_place, place_at_point, place_of_word_index,
966 point_at_place, word_index_of_place,
967 };
968
969 /// The caret positions of a line holding `n` characters: the header
970 /// first, then each character.
971 fn carets(n: u32) -> impl Iterator<Item = Option<u32>> {
972 std::iter::once(None).chain((0..n).map(Some))
973 }
974 use crate::geom;
975 use crate::vt::{self, Config, Metrics};
976 use kurbo::Point;
977
978 /// A single-line plate a hundred wide and thirty tall, the shape
979 /// `text_form.pdf`'s field has.
980 fn config() -> Config {
981 Config {
982 plate: geom::rect(101.0, 101.0, 199.0, 129.0),
983 font_size: 12.0,
984 ..Config::default()
985 }
986 }
987
988 /// Helvetica's advance widths, per mille, for the characters the tests
989 /// use. Hand-transcribed so the arithmetic below can be checked by hand
990 /// rather than against whatever the font loader answers.
991 fn helvetica(ch: u32) -> i32 {
992 match char::from_u32(ch) {
993 Some('A' | 'B' | 'E') => 667,
994 Some('C' | 'D' | 'H') => 722,
995 Some('F') => 611,
996 Some('G') => 778,
997 Some(' ') => 278,
998 _ => 556,
999 }
1000 }
1001
1002 fn metrics() -> Metrics<'static> {
1003 Metrics {
1004 width: &helvetica,
1005 ascent: 723,
1006 descent: -207,
1007 }
1008 }
1009
1010 /// Every character ten wide, which makes midpoints land on fives.
1011 fn ten(_: u32) -> i32 {
1012 10
1013 }
1014
1015 fn tens() -> Metrics<'static> {
1016 Metrics {
1017 width: &ten,
1018 ascent: 1000,
1019 descent: -200,
1020 }
1021 }
1022
1023 /// The offset a single-line field is drawn with: its one line centred in
1024 /// its plate. The same expression `ap::field_body::vertical_offset`
1025 /// computes, restated here so the tests do not depend on that module.
1026 fn centred(layout: &vt::Layout, config: &Config) -> (f32, f32) {
1027 let content = layout.content_rect_pdf(config.plate);
1028 (
1029 0.0,
1030 (geom::height(content) - geom::height(config.plate)) * 0.5,
1031 )
1032 }
1033
1034 fn at(config: &Config, metrics: &Metrics<'_>, text: &str, x: f32, y: f32) -> Place {
1035 let layout = vt::layout(text, config, metrics);
1036 let offset = centred(&layout, config);
1037 place_at_point(
1038 &layout,
1039 config.plate,
1040 config,
1041 metrics,
1042 offset,
1043 Point::new(f64::from(x), f64::from(y)),
1044 )
1045 }
1046
1047 /// The acceptance geometry: `text_form.pdf`'s field, Helvetica 12, the
1048 /// eight characters the embeddertest types, and the click it makes.
1049 ///
1050 /// The field's `/Rect` is `[100 100 200 130]` and its default border is
1051 /// one unit, so the plate starts at x = 101. Helvetica 12 sets
1052 /// `ABCDEFGH` at these advances:
1053 ///
1054 /// ```text
1055 /// A 101.000 .. 109.004 midpoint 105.002
1056 /// B 109.004 .. 117.008 midpoint 113.006
1057 /// C 117.008 .. 125.672 midpoint 121.340
1058 /// D 125.672 .. 134.336 midpoint 130.004
1059 /// E 134.336 .. 142.340 midpoint 138.338
1060 /// ```
1061 ///
1062 /// A click at x = 134 is past D's midpoint and short of E's, so the caret
1063 /// lands after D — after exactly four characters, which is what
1064 /// `InsertTextInPopulatedTextFieldMiddle` observes when it inserts
1065 /// `Hello` and reads back `ABCDHelloEFGH`.
1066 #[test]
1067 fn the_embeddertests_middle_click_lands_after_four_characters() {
1068 let config = config();
1069 let metrics = metrics();
1070 let layout = vt::layout("ABCDEFGH", &config, &metrics);
1071 let place = place_at_point(
1072 &layout,
1073 config.plate,
1074 &config,
1075 &metrics,
1076 centred(&layout, &config),
1077 Point::new(134.0, 115.0),
1078 );
1079 assert_eq!(place.word, Some(3), "the caret sits after D");
1080 assert_eq!(
1081 word_index_of_place(&layout, place),
1082 4,
1083 "four characters precede the caret"
1084 );
1085 }
1086
1087 /// The two neighbouring embeddertests, which calibrate the same rule at
1088 /// the ends: a click at the field's left edge inserts in front of
1089 /// everything, and one at x = 166 inserts behind it.
1090 #[test]
1091 fn the_same_field_clicked_at_either_end_gives_the_two_extremes() {
1092 let config = config();
1093 let metrics = metrics();
1094 let layout = vt::layout("ABCDEFGH", &config, &metrics);
1095 let offset = centred(&layout, &config);
1096 let begin = place_at_point(
1097 &layout,
1098 config.plate,
1099 &config,
1100 &metrics,
1101 offset,
1102 Point::new(102.0, 115.0),
1103 );
1104 assert_eq!(begin.word, None, "before the first character");
1105 assert_eq!(word_index_of_place(&layout, begin), 0);
1106
1107 let end = place_at_point(
1108 &layout,
1109 config.plate,
1110 &config,
1111 &metrics,
1112 offset,
1113 Point::new(166.0, 115.0),
1114 );
1115 assert_eq!(end.word, Some(7), "after the last character");
1116 assert_eq!(word_index_of_place(&layout, end), 8);
1117 }
1118
1119 /// The tie-break itself, on a plate where the midpoints are round
1120 /// numbers. Every character is ten wide, so the first sits at 101..111
1121 /// with its midpoint at 106.
1122 #[test]
1123 fn a_click_exactly_on_a_midpoint_lands_before_that_character() {
1124 let config = config();
1125 let metrics = tens();
1126 // Ten wide at size twelve is 10 * 12 * 0.001 = 0.12 per character,
1127 // so use a size that makes the arithmetic whole.
1128 let config = Config {
1129 font_size: 1000.0,
1130 ..config
1131 };
1132 // Each character is now ten units wide: 101..111, 111..121, ...
1133 assert_eq!(at(&config, &metrics, "abc", 106.0, 115.0).word, None);
1134 // A hair past it, and the caret moves after the character.
1135 assert_eq!(at(&config, &metrics, "abc", 106.001, 115.0).word, Some(0));
1136 // A hair before, and it does not.
1137 assert_eq!(at(&config, &metrics, "abc", 105.999, 115.0).word, None);
1138 // The second character's midpoint, 116, behaves the same way.
1139 assert_eq!(at(&config, &metrics, "abc", 116.0, 115.0).word, Some(0));
1140 assert_eq!(at(&config, &metrics, "abc", 116.001, 115.0).word, Some(1));
1141 }
1142
1143 /// Left of, right of, above and below the text. None of these fail, and
1144 /// each clamps to the nearest place rather than to nothing.
1145 #[test]
1146 fn points_outside_the_content_clamp_to_its_ends() {
1147 let config = config();
1148 let metrics = metrics();
1149 let layout = vt::layout("ABCDEFGH", &config, &metrics);
1150 let offset = centred(&layout, &config);
1151 let ask = |x: f32, y: f32| {
1152 place_at_point(
1153 &layout,
1154 config.plate,
1155 &config,
1156 &metrics,
1157 offset,
1158 Point::new(f64::from(x), f64::from(y)),
1159 )
1160 };
1161 // Far left of the first character, on the line.
1162 assert_eq!(ask(-1000.0, 115.0).word, None);
1163 // Far right of the last, on the line.
1164 assert_eq!(ask(1000.0, 115.0).word, Some(7));
1165 // Above everything: the layout's first place.
1166 assert_eq!(ask(134.0, 10_000.0), begin_place(&layout));
1167 // Below everything: its last.
1168 assert_eq!(ask(134.0, -10_000.0), end_place(&layout));
1169 }
1170
1171 /// An empty field has exactly one place, and every click finds it.
1172 #[test]
1173 fn an_empty_field_answers_its_only_place_from_anywhere() {
1174 let config = config();
1175 let metrics = metrics();
1176 let layout = vt::layout("", &config, &metrics);
1177 for (x, y) in [
1178 (102.0, 115.0),
1179 (198.0, 115.0),
1180 (134.0, 10_000.0),
1181 (134.0, -10_000.0),
1182 (-500.0, 115.0),
1183 ] {
1184 let place = place_at_point(
1185 &layout,
1186 config.plate,
1187 &config,
1188 &metrics,
1189 centred(&layout, &config),
1190 Point::new(x, y),
1191 );
1192 assert_eq!(place.word, None, "at ({x}, {y})");
1193 assert_eq!(word_index_of_place(&layout, place), 0);
1194 }
1195 }
1196
1197 /// A multi-line field: which line a y picks, and that an empty paragraph
1198 /// between two full ones is reachable.
1199 #[test]
1200 fn a_blank_line_between_paragraphs_is_its_own_place() {
1201 let config = Config {
1202 plate: geom::rect(0.0, 0.0, 200.0, 200.0),
1203 font_size: 10.0,
1204 multi_line: true,
1205 ..Config::default()
1206 };
1207 let metrics = tens();
1208 let layout = vt::layout("ab\n\ncd", &config, &metrics);
1209 assert_eq!(layout.sections.len(), 3, "three paragraphs");
1210 assert!(
1211 layout.sections.get(1).is_some_and(|s| s.words.is_empty()),
1212 "the middle one is empty"
1213 );
1214 // The empty paragraph's only place, reached through the index pair.
1215 let blank = place_of_word_index(&layout, 3);
1216 assert_eq!(blank, Place::new(1, 0, None));
1217 assert_eq!(word_index_of_place(&layout, blank), 3);
1218 }
1219
1220 /// A defaulted `Place` is the beginning of the text, not the position
1221 /// after the first character.
1222 #[test]
1223 fn a_default_place_is_the_start_and_not_a_zero_word() {
1224 assert_eq!(Place::default(), Place::START);
1225 assert_eq!(Place::default(), Place::start());
1226 assert_eq!(Place::START.word, None, "a zero word is after character 0");
1227 }
1228
1229 /// A wrapped line's header is the previous line's end, and it is a
1230 /// *caret position*, not zero.
1231 ///
1232 /// A header on any line but the first folds onto the place before it
1233 /// before the count begins. Without the fold a click at the start of the
1234 /// second visual line reports index 0 — the start of the whole field —
1235 /// and the next keystroke is inserted there.
1236 #[test]
1237 fn a_wrapped_lines_header_indexes_to_the_end_of_the_line_above() {
1238 // `tens()` is ten thousandths of an em, so at 10pt each character
1239 // advances 0.1 units: a 0.45-unit plate holds four per line and
1240 // "abcdefgh" wraps after "abcd".
1241 let config = Config {
1242 plate: geom::rect(0.0, 0.0, 0.45, 200.0),
1243 font_size: 10.0,
1244 multi_line: true,
1245 auto_return: true,
1246 ..Config::default()
1247 };
1248 let layout = vt::layout("abcdefgh", &config, &tens());
1249 let section = layout.sections.first().expect("one section");
1250 assert!(section.lines.len() >= 2, "the text must wrap: {section:?}");
1251 let first_line_end = section.lines.first().expect("a first line").last_word();
1252
1253 // The second line's header, which is what a click left of its first
1254 // midpoint produces.
1255 let header = Place::new(0, 1, None);
1256 assert_eq!(
1257 word_index_of_place(&layout, header),
1258 word_index_of_place(&layout, Place::new(0, 0, first_line_end)),
1259 "the header must be the line above's end, not the field's start"
1260 );
1261 assert_ne!(word_index_of_place(&layout, header), 0);
1262 }
1263
1264 /// A place naming a section the layout does not have is the end, and the
1265 /// end is not one past it.
1266 ///
1267 /// The section-break character is counted between sections and not after
1268 /// the last one, so the clamp adds no trailing break.
1269 #[test]
1270 fn a_place_past_the_last_section_indexes_to_the_very_end() {
1271 let config = Config {
1272 plate: geom::rect(0.0, 0.0, 200.0, 200.0),
1273 font_size: 10.0,
1274 multi_line: true,
1275 ..Config::default()
1276 };
1277 for text in ["abc", "ab\ncd", "a\nb\nc"] {
1278 let layout = vt::layout(text, &config, &tens());
1279 assert_eq!(
1280 word_index_of_place(&layout, Place::new(9, 0, Some(0))),
1281 word_index_of_place(&layout, end_place(&layout)),
1282 "{text:?}"
1283 );
1284 }
1285 }
1286
1287 /// Every place a layout can name survives being turned into an index and
1288 /// back.
1289 #[test]
1290 fn every_place_survives_the_index_round_trip() {
1291 let config = Config {
1292 plate: geom::rect(0.0, 0.0, 200.0, 200.0),
1293 font_size: 10.0,
1294 multi_line: true,
1295 ..Config::default()
1296 };
1297 let metrics = tens();
1298 for text in ["", "a", "abc", "ab\ncd", "ab\n\ncd", "a\nb\nc\nd"] {
1299 let layout = vt::layout(text, &config, &metrics);
1300 for (s, section) in layout.sections.iter().enumerate() {
1301 for (l, line) in section.lines.iter().enumerate() {
1302 // The header, then every character the line covers.
1303 let words = std::iter::once(None)
1304 .chain(line.words.clone().into_iter().flatten().map(Some));
1305 for word in words {
1306 let place = Place::new(
1307 u32::try_from(s).unwrap_or(0),
1308 u32::try_from(l).unwrap_or(0),
1309 word,
1310 );
1311 let index = word_index_of_place(&layout, place);
1312 // A later line's header is not a caret position of
1313 // its own: it collapses to the end of the line above,
1314 // which is where the round trip lands. Asserting the
1315 // collapse *target* is the point — skipping the case
1316 // is what let it collapse to zero unnoticed.
1317 let expected = if word.is_none() && l > 0 {
1318 let previous = section.lines.get(l - 1).expect("a line above");
1319 place_of_word_index(
1320 &layout,
1321 word_index_of_place(
1322 &layout,
1323 Place::new(
1324 u32::try_from(s).unwrap_or(0),
1325 u32::try_from(l - 1).unwrap_or(0),
1326 previous.last_word(),
1327 ),
1328 ),
1329 )
1330 } else {
1331 place
1332 };
1333 assert_eq!(
1334 place_of_word_index(&layout, index),
1335 expected,
1336 "{text:?} at {place:?} (index {index})"
1337 );
1338 }
1339 }
1340 }
1341 }
1342 }
1343
1344 /// An index past the text's end is the last place, not a panic.
1345 #[test]
1346 fn an_index_past_the_end_answers_the_last_place() {
1347 let config = config();
1348 let metrics = metrics();
1349 let layout = vt::layout("ABC", &config, &metrics);
1350 assert_eq!(place_of_word_index(&layout, 3), end_place(&layout));
1351 assert_eq!(place_of_word_index(&layout, 9999), end_place(&layout));
1352 }
1353
1354 /// Clicking where a caret is drawn puts the caret back where it was:
1355 /// `place_at_point` and `point_at_place` agree at every position.
1356 #[test]
1357 fn a_caret_clicked_at_its_own_position_does_not_move() {
1358 let config = config();
1359 let metrics = metrics();
1360 let layout = vt::layout("ABCDEFGH", &config, &metrics);
1361 let offset = centred(&layout, &config);
1362 for word in carets(8) {
1363 let place = Place::new(0, 0, word);
1364 let point = point_at_place(&layout, config.plate, &config, &metrics, offset, place);
1365 // A click *at* the caret is exactly on a character boundary,
1366 // which is a half-width past the previous character's midpoint —
1367 // strictly past it, so the caret stays put.
1368 let back = place_at_point(
1369 &layout,
1370 config.plate,
1371 &config,
1372 &metrics,
1373 offset,
1374 Point::new(point.x, point.y - 1.0),
1375 );
1376 assert_eq!(back, place, "caret at {word:?} moved");
1377 }
1378 }
1379
1380 /// The caret rectangle a focused field draws: as wide as asked, as tall
1381 /// as its line, and starting where the character it follows ends.
1382 #[test]
1383 fn the_caret_rectangle_spans_its_line() {
1384 let config = config();
1385 let metrics = metrics();
1386 let layout = vt::layout("ABCDEFGH", &config, &metrics);
1387 let rect = caret_rect(
1388 &layout,
1389 config.plate,
1390 &config,
1391 &metrics,
1392 (0.0, 0.0),
1393 Place::new(0, 0, Some(3)),
1394 0.4,
1395 );
1396 // After D, whose advance ends at 134.336.
1397 assert!(
1398 (geom::left(rect) - 134.336).abs() < 0.01,
1399 "{:?}",
1400 geom::left(rect)
1401 );
1402 assert!((geom::width(rect) - 0.4).abs() < 1e-5);
1403 // As tall as Helvetica 12's ascent plus descent.
1404 let expected = (723.0 + 207.0) * 12.0 * 0.001;
1405 assert!(
1406 (geom::height(rect) - expected).abs() < 0.01,
1407 "{:?} vs {expected}",
1408 geom::height(rect)
1409 );
1410 }
1411
1412 /// A place naming something the layout does not have answers a valid
1413 /// point rather than panicking — the caret can outlive an edit that
1414 /// shortened the text.
1415 #[test]
1416 fn a_place_past_the_layout_still_answers_a_point() {
1417 let config = config();
1418 let metrics = metrics();
1419 let layout = vt::layout("AB", &config, &metrics);
1420 for place in [
1421 Place::new(9, 0, Some(0)),
1422 Place::new(0, 9, Some(0)),
1423 Place::new(0, 0, Some(99)),
1424 Place::new(u32::MAX, u32::MAX, Some(u32::MAX)),
1425 ] {
1426 let point = point_at_place(&layout, config.plate, &config, &metrics, (0.0, 0.0), place);
1427 assert!(point.x.is_finite() && point.y.is_finite(), "{place:?}");
1428 // The index of an out-of-range place is whatever the walk
1429 // accumulated before it ran out — a number, never a panic — and
1430 // feeding it back always lands on a place the layout does hold.
1431 let index = word_index_of_place(&layout, place);
1432 let back = place_of_word_index(&layout, index);
1433 assert!(
1434 (back.section as usize) < layout.sections.len(),
1435 "{place:?} came back as {back:?}"
1436 );
1437 }
1438 }
1439
1440 /// A comb field's cells: the tail each character carries widens its
1441 /// advance, so the midpoints are the cells' midpoints and not the
1442 /// glyphs'.
1443 #[test]
1444 fn comb_cells_hit_test_on_the_cell_rather_than_the_glyph() {
1445 let config = Config {
1446 plate: geom::rect(0.0, 0.0, 100.0, 20.0),
1447 font_size: 10.0,
1448 char_array: 4,
1449 ..Config::default()
1450 };
1451 let metrics = tens();
1452 let layout = vt::layout("ab", &config, &metrics);
1453 // Four cells across a hundred units: 25 each. The first character's
1454 // advance therefore runs a full cell, so its midpoint is far right of
1455 // the glyph's own centre.
1456 let first = layout
1457 .sections
1458 .first()
1459 .and_then(|s| s.words.first())
1460 .copied()
1461 .expect("a first character");
1462 let width = vt::word_width(&first, &config, &metrics, config.font_size);
1463 assert!(width > 20.0, "the cell tail widened the advance: {width}");
1464 let mid = first.x + width * 0.5;
1465 let before = place_at_point(
1466 &layout,
1467 config.plate,
1468 &config,
1469 &metrics,
1470 (0.0, 0.0),
1471 Point::new(f64::from(mid) - 0.01, 10.0),
1472 );
1473 let after = place_at_point(
1474 &layout,
1475 config.plate,
1476 &config,
1477 &metrics,
1478 (0.0, 0.0),
1479 Point::new(f64::from(mid) + 0.01, 10.0),
1480 );
1481 assert_eq!(before.word, None);
1482 assert_eq!(after.word, Some(0));
1483 }
1484
1485 /// Every character five hundred per mille, so a twelve-point run steps in
1486 /// whole sixes and the caret positions below are exact.
1487 fn half_em(_: u32) -> i32 {
1488 500
1489 }
1490
1491 fn halves() -> Metrics<'static> {
1492 Metrics {
1493 width: &half_em,
1494 ascent: 1000,
1495 descent: -200,
1496 }
1497 }
1498
1499 /// A three-character Hebrew word in a plate whose left edge is one, laid
1500 /// out at twelve points with six-unit advances.
1501 ///
1502 /// The run is right-to-left, so it is drawn right to left across
1503 /// `1 ..= 19`: the first character in logical order occupies `13 ..= 19`,
1504 /// the second `7 ..= 13`, the third `1 ..= 7`.
1505 fn rtl_run() -> (Config, Metrics<'static>, vt::Layout) {
1506 let config = Config {
1507 plate: geom::rect(1.0, 1.0, 99.0, 29.0),
1508 font_size: 12.0,
1509 ..Config::default()
1510 };
1511 let metrics = halves();
1512 let layout = vt::layout("\u{5D1}\u{5D7}\u{5E8}", &config, &metrics);
1513 (config, metrics, layout)
1514 }
1515
1516 /// The four caret positions of a right-to-left word, in place order.
1517 ///
1518 /// A character's caret sits at its own left edge when the run is
1519 /// right-to-left rather than at its trailing edge, and the line header
1520 /// sits at the line's *right* end. Together those walk the caret
1521 /// **leftward** as the place advances: the header at the run's right end,
1522 /// each further character one advance left of the last.
1523 ///
1524 /// Adding the advance in both directions instead — which is what a
1525 /// left-to-right-only port does — gives `1, 19, 13, 7`: the header at the
1526 /// wrong end, and every other caret one full advance right of the gap it
1527 /// names.
1528 #[test]
1529 fn a_right_to_left_words_carets_walk_leftward_from_its_right_edge() {
1530 let (config, metrics, layout) = rtl_run();
1531 let got: Vec<f64> = carets(3)
1532 .map(|word| {
1533 point_at_place(
1534 &layout,
1535 config.plate,
1536 &config,
1537 &metrics,
1538 (0.0, 0.0),
1539 Place::new(0, 0, word),
1540 )
1541 .x
1542 })
1543 .collect();
1544 let want = [19.0, 13.0, 7.0, 1.0];
1545 for (place, (&got, &want)) in got.iter().zip(want.iter()).enumerate() {
1546 assert!(
1547 (got - want).abs() < 1e-4,
1548 "place {} caret is {got}, want {want}: {got:?}",
1549 i32::try_from(place).unwrap_or(0) - 1
1550 );
1551 }
1552 }
1553
1554 /// The same four positions through [`caret_rect`], which is what the
1555 /// focused field actually draws, so the rectangle's left edge is the
1556 /// caret's x rather than that x plus a width.
1557 #[test]
1558 fn the_drawn_caret_rectangle_follows_the_same_right_to_left_walk() {
1559 let (config, metrics, layout) = rtl_run();
1560 for (word, want) in carets(3).zip([19.0_f64, 13.0, 7.0, 1.0]) {
1561 let rect = caret_rect(
1562 &layout,
1563 config.plate,
1564 &config,
1565 &metrics,
1566 (0.0, 0.0),
1567 Place::new(0, 0, word),
1568 0.4,
1569 );
1570 assert!(
1571 (rect.x0 - want).abs() < 1e-4,
1572 "caret after {word:?} starts at {}, want {want}",
1573 rect.x0
1574 );
1575 assert!((rect.x1 - rect.x0 - 0.4).abs() < 1e-4, "{rect:?}");
1576 }
1577 }
1578
1579 /// A left-to-right line is untouched: its header is the left edge and
1580 /// every caret is a character's right edge, which is what every existing
1581 /// assertion in this module already depends on.
1582 #[test]
1583 fn a_left_to_right_run_still_walks_rightward_from_its_left_edge() {
1584 let config = Config {
1585 plate: geom::rect(1.0, 1.0, 99.0, 29.0),
1586 font_size: 12.0,
1587 ..Config::default()
1588 };
1589 let metrics = halves();
1590 let layout = vt::layout("abc", &config, &metrics);
1591 let got: Vec<f64> = carets(3)
1592 .map(|word| {
1593 point_at_place(
1594 &layout,
1595 config.plate,
1596 &config,
1597 &metrics,
1598 (0.0, 0.0),
1599 Place::new(0, 0, word),
1600 )
1601 .x
1602 })
1603 .collect();
1604 for (place, (&got, &want)) in got.iter().zip([1.0, 7.0, 13.0, 19.0].iter()).enumerate() {
1605 assert!(
1606 (got - want).abs() < 1e-4,
1607 "place {} caret is {got}, want {want}",
1608 i32::try_from(place).unwrap_or(0) - 1
1609 );
1610 }
1611 }
1612
1613 /// A click in a right-to-left run answers one of the run's two ends, and
1614 /// the **middle** character's midpoint is what divides them.
1615 ///
1616 /// The word search is a **bisection**, and the midpoint predicate it
1617 /// bisects on is monotone only along a left-to-right line. Down a
1618 /// descending run the first probe is the middle character and the answer
1619 /// follows that one probe alone: a click **left** of its midpoint narrows
1620 /// to index 0, whose own midpoint is the run's *rightmost*, so the
1621 /// post-loop test there fails and the answer is the header; a click right
1622 /// of it narrows to the last index, whose midpoint is the run's
1623 /// *leftmost*, which the click is past.
1624 ///
1625 /// The two places are the run's two logical ends — its beginning and its
1626 /// end — and in a right-to-left run those are drawn at the right and left
1627 /// edges respectively. So the caret crosses the whole run at the middle
1628 /// character's midpoint rather than stepping character by character. The
1629 /// assertion here is that transcription, not a claim that it is the
1630 /// nicest answer. What a scan-based search does instead is worse and not
1631 /// merely different — it answers the header for **every** click in the
1632 /// run, so the caret never moves at all.
1633 #[test]
1634 fn a_click_in_a_right_to_left_run_answers_the_end_the_bisection_narrows_to() {
1635 let (config, metrics, layout) = rtl_run();
1636 let at = |x: f64| {
1637 place_at_point(
1638 &layout,
1639 config.plate,
1640 &config,
1641 &metrics,
1642 (0.0, 0.0),
1643 Point::new(x, 15.0),
1644 )
1645 .word
1646 };
1647 let caret = |x: f64| {
1648 point_at_place(
1649 &layout,
1650 config.plate,
1651 &config,
1652 &metrics,
1653 (0.0, 0.0),
1654 Place::new(0, 0, at(x)),
1655 )
1656 .x
1657 };
1658 // The run occupies PDF x 1..19. The middle character is drawn over
1659 // 7..13, so its midpoint is at 10 — the one probe that decides.
1660 assert_eq!(at(2.0), None, "left of the middle midpoint: the header");
1661 assert_eq!(at(9.9), None);
1662 assert_eq!(at(10.0), None, "exactly on the midpoint is not past it");
1663 assert_eq!(at(10.1), Some(2), "past it: the run's last character");
1664 assert_eq!(at(18.0), Some(2));
1665
1666 // The two places are the run's logical ends, drawn at its right and
1667 // left edges: the caret crosses the run rather than stepping across
1668 // it.
1669 assert!((caret(2.0) - 19.0).abs() < 1e-4, "{}", caret(2.0));
1670 assert!((caret(18.0) - 1.0).abs() < 1e-4, "{}", caret(18.0));
1671 }
1672
1673 /// A line whose first character is right-to-left answers its right edge
1674 /// for the header even when a left-to-right run follows it, because
1675 /// `GetLineCaretX` reads the **first word alone**.
1676 #[test]
1677 fn a_mixed_line_takes_its_header_from_its_first_word_only() {
1678 let config = Config {
1679 plate: geom::rect(1.0, 1.0, 99.0, 29.0),
1680 font_size: 12.0,
1681 ..Config::default()
1682 };
1683 let metrics = halves();
1684 let rtl_first = vt::layout("\u{5D1}\u{5D7}ab", &config, &metrics);
1685 let header = point_at_place(
1686 &rtl_first,
1687 config.plate,
1688 &config,
1689 &metrics,
1690 (0.0, 0.0),
1691 Place::START,
1692 )
1693 .x;
1694 let line_width = rtl_first
1695 .sections
1696 .first()
1697 .and_then(|section| section.lines.first())
1698 .map_or(0.0, |line| line.width);
1699 assert!(
1700 (header - f64::from(1.0 + line_width)).abs() < 1e-4,
1701 "header is the line's right edge: {header} against width {line_width}"
1702 );
1703
1704 // And the mirror: a left-to-right first word keeps the left edge even
1705 // with a right-to-left run behind it.
1706 let ltr_first = vt::layout("ab\u{5D1}\u{5D7}", &config, &metrics);
1707 let header = point_at_place(
1708 <r_first,
1709 config.plate,
1710 &config,
1711 &metrics,
1712 (0.0, 0.0),
1713 Place::START,
1714 )
1715 .x;
1716 assert!((header - 1.0).abs() < 1e-4, "{header}");
1717 }
1718
1719 /// An empty line has no first character to take a direction from, so its
1720 /// header stays at the line's own left edge.
1721 #[test]
1722 fn an_empty_line_keeps_its_left_edge_for_a_header() {
1723 let config = Config {
1724 plate: geom::rect(1.0, 1.0, 99.0, 29.0),
1725 font_size: 12.0,
1726 ..Config::default()
1727 };
1728 let metrics = halves();
1729 let layout = vt::layout("", &config, &metrics);
1730 let header = point_at_place(
1731 &layout,
1732 config.plate,
1733 &config,
1734 &metrics,
1735 (0.0, 0.0),
1736 Place::START,
1737 )
1738 .x;
1739 assert!((header - 1.0).abs() < 1e-4, "{header}");
1740 }
1741
1742 /// The `password` fixture's own geometry, laid out with the size it asks
1743 /// for — **zero**, meaning automatic.
1744 ///
1745 /// `testing/resources/pixel/password.in`'s second widget is
1746 /// `/Rect [100 160 200 190]` with `/MaxLen 5`, `/Q 2` and the password
1747 /// flag, and the file carries no `/DA` and no `/DR` at all — so the size
1748 /// requested is zero and the fallback Helvetica substitutes to Arimo
1749 /// (ascent 905, descent −211, `'*'` 389/1000). The evt types `tiger` and
1750 /// four more characters the limit drops.
1751 fn password_field() -> (Config, Metrics<'static>, vt::Layout) {
1752 fn arimo(ch: u32) -> i32 {
1753 match char::from_u32(ch) {
1754 Some('*') => 389,
1755 Some('t') => 278,
1756 Some('i') => 222,
1757 Some('r') => 333,
1758 _ => 556,
1759 }
1760 }
1761 let config = Config {
1762 // `/Rect` deflated by the default one-unit border.
1763 plate: geom::rect(101.0, 161.0, 199.0, 189.0),
1764 alignment: vt::Alignment::Right,
1765 font_size: 0.0,
1766 sub_word: Some('*'),
1767 limit_char: 5,
1768 ..Config::default()
1769 };
1770 let metrics = Metrics {
1771 width: &arimo,
1772 ascent: 905,
1773 descent: -211,
1774 };
1775 let layout = vt::layout("tigerssss", &config, &metrics);
1776 (config, metrics, layout)
1777 }
1778
1779 /// An auto-sized field's carets are measured at the size the layout
1780 /// resolved, not at the zero its config still holds.
1781 ///
1782 /// This is `password`'s defect, in one assertion. `vt::layout` resolves
1783 /// the zero to 25 and writes it to [`vt::Layout::font_size`] — which is
1784 /// what `CPVT_VariableText::Rearrange` does with
1785 /// `SetFontSize(GetAutoFontSize())` — and `edit_ap` already draws with
1786 /// that. Reading `config.font_size` here instead made every advance zero,
1787 /// so the caret after the last of five asterisks sat on that asterisk's
1788 /// **left** edge: 189.275 rather than 199.0, which is the ten device
1789 /// columns the golden showed.
1790 ///
1791 /// The five carets are one advance apart and the last is the plate's own
1792 /// right edge, because the field is right-aligned and full.
1793 #[test]
1794 fn an_auto_sized_fields_carets_are_measured_at_the_size_the_layout_resolved() {
1795 let (config, metrics, layout) = password_field();
1796 assert!(
1797 (layout.font_size - 25.0).abs() < 1e-4,
1798 "auto size resolved to {}",
1799 layout.font_size
1800 );
1801 assert!(
1802 config.font_size == 0.0,
1803 "the config still holds the request, which is the whole point"
1804 );
1805 // The limit keeps five characters of the nine typed.
1806 assert_eq!(end_place(&layout), Place::new(0, 0, Some(4)));
1807
1808 let advance = 389.0 * 25.0 / 1000.0;
1809 let first = 150.375_f32;
1810 for word in carets(5) {
1811 let got = point_at_place(
1812 &layout,
1813 config.plate,
1814 &config,
1815 &metrics,
1816 (0.0, 0.0),
1817 Place::new(0, 0, word),
1818 )
1819 .x;
1820 // The header sits at the line's left edge; character `w` sits
1821 // one advance further along than character `w - 1`.
1822 let steps = u16::try_from(word.map_or(0, |w| w.saturating_add(1)))
1823 .map_or(f32::from(u16::MAX), f32::from);
1824 let want = f64::from(first + advance * steps);
1825 assert!(
1826 (got - want).abs() < 1e-3,
1827 "caret after {word:?} is {got}, want {want}"
1828 );
1829 }
1830 }
1831
1832 /// The same size, through [`caret_rect`], which is what the focused field
1833 /// draws — the rectangle's x **and** its height.
1834 ///
1835 /// The height is the second half of the same defect: `line_extent`'s
1836 /// fallback read the config too, so a place naming a line the layout does
1837 /// not have would have answered a caret of no height at all.
1838 #[test]
1839 fn the_drawn_caret_of_an_auto_sized_field_has_the_resolved_sizes_height() {
1840 let (config, metrics, layout) = password_field();
1841 let rect = caret_rect(
1842 &layout,
1843 config.plate,
1844 &config,
1845 &metrics,
1846 (0.0, 0.0),
1847 end_place(&layout),
1848 0.4,
1849 );
1850 // Flush with the plate's right edge: right-aligned and full.
1851 assert!((rect.x0 - 199.0).abs() < 1e-3, "{rect:?}");
1852 assert!((rect.x1 - rect.x0 - 0.4).abs() < 1e-4, "{rect:?}");
1853 // (905 + 211) * 25 / 1000, the line's own ascent and descent.
1854 assert!((rect.y1 - rect.y0 - 27.9).abs() < 1e-3, "{rect:?}");
1855
1856 // A place naming a line the layout does **not** have keeps the
1857 // config's size, which for an auto-sized field is a caret of no
1858 // height. That is deliberate and measured — see `line_extent` — and
1859 // it is asserted here so substituting the layout's size turns this
1860 // red rather than moving 170 conformance rows silently.
1861 let missing = caret_rect(
1862 &layout,
1863 config.plate,
1864 &config,
1865 &metrics,
1866 (0.0, 0.0),
1867 Place::new(0, 7, Some(0)),
1868 0.4,
1869 );
1870 assert!((missing.y1 - missing.y0).abs() < 1e-6, "{missing:?}");
1871 }
1872
1873 /// A click into an auto-sized field lands by the midpoint rule at the
1874 /// resolved size, not by position alone.
1875 ///
1876 /// The third face of the same defect. At a zero size every advance is
1877 /// zero, so every midpoint collapses onto the character's own origin and
1878 /// the bisection answers whichever end its first probe falls on — the
1879 /// caret would jump to a line end instead of stepping.
1880 #[test]
1881 fn a_click_in_an_auto_sized_field_uses_the_resolved_midpoints() {
1882 let (config, metrics, layout) = password_field();
1883 let advance = 389.0 * 25.0 / 1000.0;
1884 let first = f64::from(150.375_f32);
1885 for index in 0..5 {
1886 // A hair past the midpoint of character `index` lands after it.
1887 let x = first + advance * (f64::from(index) + 0.5) + 0.01;
1888 let place = place_at_point(
1889 &layout,
1890 config.plate,
1891 &config,
1892 &metrics,
1893 (0.0, 0.0),
1894 Point::new(x, 175.0),
1895 );
1896 assert_eq!(place.word, Some(index), "click at {x} landed at {place:?}");
1897 }
1898 // Left of the first character's midpoint is the line header.
1899 let header = place_at_point(
1900 &layout,
1901 config.plate,
1902 &config,
1903 &metrics,
1904 (0.0, 0.0),
1905 Point::new(first + advance * 0.5 - 0.01, 175.0),
1906 );
1907 assert_eq!(header.word, None, "{header:?}");
1908 }
1909
1910 /// A field that asks for an explicit size is untouched, which is why
1911 /// every other assertion in this module still reads the same.
1912 ///
1913 /// The two sizes are the same number there, so the fix can only move an
1914 /// auto-sized field — and an auto-sized field is exactly the one no
1915 /// assertion covered.
1916 #[test]
1917 fn an_explicitly_sized_field_answers_the_same_as_it_always_did() {
1918 let config = config();
1919 let metrics = metrics();
1920 let layout = vt::layout("ABC", &config, &metrics);
1921 assert!((layout.font_size - config.font_size).abs() < f32::EPSILON);
1922 let caret = point_at_place(
1923 &layout,
1924 config.plate,
1925 &config,
1926 &metrics,
1927 (0.0, 0.0),
1928 Place::new(0, 0, Some(0)),
1929 )
1930 .x;
1931 // 'A' is 667/1000 at twelve points, from the plate's left edge.
1932 assert!(
1933 (caret - (101.0 + 667.0 * 12.0 / 1000.0)).abs() < 1e-3,
1934 "{caret}"
1935 );
1936 }
1937}