Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/tests/list_fidelity.rs

9.8 KiB, 1 run

created by r1870400018:59588, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The list-fidelity gate: a RENDER-level check that a bullet or numbered list sets its items at the
2//! body's own baseline pitch and seats each marker on its item's baseline, rather than tighter and
3//! lower as the pre-cap-edge list layout did. It measures the placed frame of the committed
4//! `samples/hierarchy.typ` -- the same file the reader's nav panel is exercised against -- so it gates
5//! the geometry a reader actually sees, not the parse tree.
6//!
7//! Non-vacuity is the point of the exercise, so the three assertions are split across three tests, each
8//! mapped to one of the three fixes in `doc.rs`:
9//!
10//! * [`tight_list_item_pitch_equals_body_pitch`] reds if the inter-item glue stops being sized by the
11//! baselineskip rule (revert `push_item_gap` to a raw `Glue::fixed(item_skip)`): the items then set
12//! at `cap + item_skip`, tighter than the body `leading`.
13//! * [`bullet_marker_sits_on_the_line_baseline`] reds if the marker stops copying the line's text shift
14//! (revert `indent_item` to inserting the marker with shift zero): the bullet then drops by
15//! `ascender - cap` (~0.45 em) below the text baseline.
16//! * [`nested_list_indents_and_keeps_pitch`] reds on the same glue revert, at the nested level.
17//!
18//! Each test also cross-checks the measured body pitch against the theme's own `leading`, so a gate that
19//! measured nothing (an empty frame, a marker-less list) fails loudly rather than passing vacuously.
20
21use oxedyne_fe2o3_austenite::compile;
22use oxedyne_fe2o3_austenite::fonts;
23use oxedyne_fe2o3_austenite::page::PlacedKind;
24use oxedyne_fe2o3_austenite::theme::Theme;
25
26use oxedyne_fe2o3_core::prelude::*;
27
28use std::path::PathBuf;
29use std::sync::Arc;
30
31const BULLET: &str = "\u{2022}";
32
33// A quarter point, in scaled points: the geometry is exact integer arithmetic, so this is slack for
34// nothing but rounding in the cap-edge trim and the shape metrics.
35const TOL: i32 = 65536 / 4;
36
37/// One placed run of shaped text, reduced to the numbers the gate reasons about: which page it landed on,
38/// its baseline y (`top + ascent`), its left edge, its ascent, and the source string the run carries (a
39/// bullet is `"\u{2022}"`, an enumerator `"1."`, a word its letters).
40struct Run {
41 page: u32,
42 base: i32, // baseline y in scaled points: the y a reader sees the glyphs sit on
43 x: i32,
44 h: i32, // the run's ascent, uniform across a face and size
45 src: String,
46}
47
48/// Compiles `samples/hierarchy.typ` through the real assemble/author/run path and returns every placed
49/// text run in the document, in page-then-baseline order.
50fn runs() -> Outcome<Vec<Run>> {
51 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("samples").join("hierarchy.typ");
52 let (assembled, _refusals, _skip) = res!(compile::assemble(
53 &path,
54 || Ok(Arc::new(res!(fonts::libertinus()))),
55 ));
56 let rendered = res!(compile::author_and_run(assembled));
57
58 let mut out: Vec<Run> = Vec::new();
59 for page in &rendered.out.pages {
60 for placed in &page.frame.placed {
61 if let PlacedKind::Text(shaped) = &placed.kind {
62 out.push(Run {
63 page: page.number,
64 base: placed.y.raw() + placed.dims.height.raw(),
65 x: placed.x.raw(),
66 h: placed.dims.height.raw(),
67 src: shaped.source().to_string(),
68 });
69 }
70 }
71 }
72 out.sort_by(|a, b| (a.page, a.base).cmp(&(b.page, b.base)));
73 Ok(out)
74}
75
76/// The body ascent: the most common run ascent across the document. Body prose dominates every sample, so
77/// its ascent is the mode; a heading's larger ascent is a minority and drops out. This tells a body line
78/// from a heading line without reading a font metric.
79fn body_ascent(runs: &[Run]) -> i32 {
80 let mut counts: Vec<(i32, u32)> = Vec::new();
81 for r in runs {
82 match counts.iter_mut().find(|(h, _)| *h == r.h) {
83 Some(entry) => entry.1 += 1,
84 None => counts.push((r.h, 1)),
85 }
86 }
87 counts.iter().max_by_key(|(_, n)| *n).map(|(h, _)| *h).unwrap_or(0)
88}
89
90/// The body baseline pitch: the most common gap between two consecutive body-size line baselines on the
91/// document's first page. The opening paragraph alone wraps to seven lines all one `leading` apart, so
92/// the mode is `leading` whatever the lists below do -- which is what keeps this an independent reference
93/// for the list tests even when a list's own pitch is the thing under test.
94fn body_pitch(runs: &[Run]) -> i32 {
95 let body = body_ascent(runs);
96 // The opening paragraph ends where the first list marker begins; everything above that on page 1 is
97 // plain wrapped body prose. Measuring the pitch there alone keeps this reference independent of the
98 // lists under test -- the intro's own leading does not move when a list's item spacing is broken, so
99 // the reference stays put while the thing being compared to it fails, which is what makes the list
100 // tests non-vacuous.
101 let first_marker = runs.iter()
102 .filter(|r| r.page == 1 && r.src == BULLET)
103 .map(|r| r.base)
104 .min()
105 .unwrap_or(i32::MAX);
106 // Distinct intro-line baselines on page 1, ascending. Same-baseline runs (the several words of a line)
107 // collapse to one entry.
108 let mut bases: Vec<i32> = Vec::new();
109 for r in runs {
110 if r.page == 1 && r.h == body && r.base < first_marker && !bases.contains(&r.base) {
111 bases.push(r.base);
112 }
113 }
114 bases.sort();
115 // The mode of the exact consecutive gaps. The layout is exact-integer arithmetic, so every within-
116 // paragraph pitch is the very same integer `leading`; the opening paragraph alone contributes six of
117 // them, so `leading` wins the mode over the varied heading and inter-paragraph gaps -- no bucketing,
118 // and the reference is the true value to the scaled point.
119 let mut counts: Vec<(i32, u32)> = Vec::new();
120 for w in bases.windows(2) {
121 let gap = w[1] - w[0];
122 match counts.iter_mut().find(|(g, _)| *g == gap) {
123 Some(entry) => entry.1 += 1,
124 None => counts.push((gap, 1)),
125 }
126 }
127 counts.iter().max_by_key(|(_, n)| *n).map(|(g, _)| *g).unwrap_or(0)
128}
129
130/// Every bullet marker run, in page-then-baseline order -- the tight list's four, then the nested turn's
131/// four, then the loose list's three, exactly the document order.
132fn bullets(runs: &[Run]) -> Vec<&Run> {
133 runs.iter().filter(|r| r.src == BULLET).collect()
134}
135
136fn pt(sp: i32) -> f64 { sp as f64 / 65536.0 }
137
138#[test]
139fn tight_list_item_pitch_equals_body_pitch() -> Outcome<()> {
140 let runs = res!(runs());
141 let leading = Theme::default().text.leading.raw();
142 let body = body_pitch(&runs);
143
144 // The gate measured a real body pitch, and it is the theme's leading -- not a vacuous zero.
145 assert!((body - leading).abs() < TOL,
146 "measured body pitch {:.3}pt is not the theme leading {:.3}pt -- the gate measured nothing usable",
147 pt(body), pt(leading));
148
149 let bullets = bullets(&runs);
150 assert!(bullets.len() >= 4,
151 "expected at least four bullet markers, found {} -- the tight list did not render", bullets.len());
152
153 // The first four bullets are the tight list under `== Bullets, Set Tight`, its items single-line, so a
154 // marker-to-marker gap is one item's pitch. Each must equal the body pitch: a tight list sets at the
155 // body leading, its items neither touching nor gapped.
156 for pair in bullets[..4].windows(2) {
157 let pitch = pair[1].base - pair[0].base;
158 assert!((pitch - body).abs() < TOL,
159 "tight bullet item pitch {:.3}pt != body pitch {:.3}pt (leading {:.3}pt): the list is set \
160 {} the body it sits in",
161 pt(pitch), pt(body), pt(leading),
162 if pitch < body { "tighter than" } else { "looser than" });
163 }
164 Ok(())
165}
166
167#[test]
168fn bullet_marker_sits_on_the_line_baseline() -> Outcome<()> {
169 let runs = res!(runs());
170 let bullets = bullets(&runs);
171 assert!(!bullets.is_empty(), "no bullet markers rendered -- nothing to seat");
172
173 // For the first tight bullet item, the marker and the item's own first word share the line, so they
174 // must share a baseline. The item text is the next run on the same line to the marker's right. Before
175 // the seating fix the marker kept shift zero while the text was raised to the cap edge, dropping the
176 // bullet by `ascender - cap` (~0.45 em) below the text.
177 let marker = bullets[0];
178 let text = runs.iter()
179 .filter(|r| r.page == marker.page && r.x > marker.x && r.src != BULLET)
180 .filter(|r| (r.base - marker.base).abs() < Theme::default().text.leading.raw())
181 .min_by_key(|r| r.x);
182 let text = res!(text.ok_or_else(|| err!(
183 "the first bullet item carried no text run to seat its marker against"; Invalid, Missing)));
184
185 let drop = (marker.base - text.base).abs();
186 assert!(drop < TOL,
187 "bullet marker baseline is {:.3}pt off the item's text baseline -- it should sit on the line, not \
188 ~0.45 em below it",
189 pt(drop));
190 Ok(())
191}
192
193#[test]
194fn nested_list_indents_and_keeps_pitch() -> Outcome<()> {
195 let runs = res!(runs());
196 let leading = Theme::default().text.leading.raw();
197 let body = body_pitch(&runs);
198 let bullets = bullets(&runs);
199 assert!(bullets.len() >= 8,
200 "expected the nested-turn bullets, found only {} markers", bullets.len());
201
202 // The tight list's own left edge (its markers share one x), the outer indent to compare against.
203 let outer_x = bullets[0].x;
204
205 // The nested turn is bullets four through eight (0-based 4..8): outer item, its two sub-items, outer
206 // item. The sub-items sit one indent to the right of the outer markers, so they are the bullets in
207 // that group whose x exceeds the outer edge.
208 let nested: Vec<&Run> = bullets[4..8].iter().copied().filter(|r| r.x > outer_x + TOL).collect();
209 assert!(nested.len() >= 2,
210 "expected two indented sub-items in the nested list, found {} -- the sub-list did not indent",
211 nested.len());
212
213 // The nesting is a real indent, not a hair.
214 assert!(nested[0].x > outer_x + TOL,
215 "nested sub-item left edge {:.3}pt is not indented past the outer edge {:.3}pt",
216 pt(nested[0].x), pt(outer_x));
217
218 // And the nested list keeps the body pitch between its own single-line items, like the top level.
219 let pitch = nested[1].base - nested[0].base;
220 assert!((pitch - body).abs() < TOL,
221 "nested item pitch {:.3}pt != body pitch {:.3}pt (leading {:.3}pt)",
222 pt(pitch), pt(body), pt(leading));
223 Ok(())
224}