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 | |
| 21 | use oxedyne_fe2o3_austenite::compile; |
| 22 | use oxedyne_fe2o3_austenite::fonts; |
| 23 | use oxedyne_fe2o3_austenite::page::PlacedKind; |
| 24 | use oxedyne_fe2o3_austenite::theme::Theme; |
| 25 | |
| 26 | use oxedyne_fe2o3_core::prelude::*; |
| 27 | |
| 28 | use std::path::PathBuf; |
| 29 | use std::sync::Arc; |
| 30 | |
| 31 | const 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. |
| 35 | const 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). |
| 40 | struct 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. |
| 50 | fn 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. |
| 79 | fn 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. |
| 94 | fn 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. |
| 132 | fn bullets(runs: &[Run]) -> Vec<&Run> { |
| 133 | runs.iter().filter(|r| r.src == BULLET).collect() |
| 134 | } |
| 135 | |
| 136 | fn pt(sp: i32) -> f64 { sp as f64 / 65536.0 } |
| 137 | |
| 138 | #[test] |
| 139 | fn 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] |
| 168 | fn 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] |
| 194 | fn 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 | } |