oxedyne/fe2o3/fe2o3_austenite/tests/page_columns.rs
13.8 KiB, 1 run
created by r1870400018:59974, 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 | //! Does `#set page(columns: n)` flow the body down equal columns, and does a parent-scoped float span them? |
| 2 | //! |
| 3 | //! Every case drives the real [`compile::assemble`]/[`compile::author_and_run`] path over a synthetic lone |
| 4 | //! document installed in the source map, then reads the placed frame: each text run's page, x, width and y. |
| 5 | //! Five assertions map to one fix each, and reverting that fix reds it (checked 2026-09-23): |
| 6 | //! |
| 7 | //! * [`two_columns_fill_left_then_right`]: the author's page-column marker and column measure. |
| 8 | //! * [`a_parent_float_spans_both_columns_above_them`]: the parent-scoped seating in the driver's `Flow`. |
| 9 | //! * [`a_parent_float_met_mid_page_relays_the_page`]: the relayout that rewinds the page for it. |
| 10 | //! * [`a_column_float_stays_in_its_column`]: the column-scoped band in the driver's `Flow`. |
| 11 | //! * [`colbreak_moves_to_the_next_column`]: the column eject. |
| 12 | //! |
| 13 | //! [`one_column_is_the_ordinary_page`] guards the other side: a document naming no columns sets full-width |
| 14 | //! lines exactly as before, which the oracle's pinned hashes confirm byte for byte. |
| 15 | |
| 16 | use oxedyne_fe2o3_austenite::compile; |
| 17 | use oxedyne_fe2o3_austenite::fonts; |
| 18 | use oxedyne_fe2o3_austenite::page::PlacedKind; |
| 19 | use oxedyne_fe2o3_austenite::vfs; |
| 20 | |
| 21 | use oxedyne_fe2o3_core::prelude::*; |
| 22 | |
| 23 | use std::collections::HashMap; |
| 24 | use std::path::PathBuf; |
| 25 | use std::sync::{ |
| 26 | Arc, |
| 27 | Mutex, |
| 28 | }; |
| 29 | |
| 30 | // The source map is a process-wide global, so the cases take turns. |
| 31 | static VFS: Mutex<()> = Mutex::new(()); |
| 32 | |
| 33 | /// One placed text run, in points: its page, left edge, right edge, top and source string. |
| 34 | #[derive(Debug, Clone)] |
| 35 | struct Run { |
| 36 | page: u32, |
| 37 | x0: f64, |
| 38 | x1: f64, |
| 39 | y: f64, |
| 40 | src: String, |
| 41 | } |
| 42 | |
| 43 | /// The page geometry every case sets on: A4 with the lone-file margins. |
| 44 | struct Geom { |
| 45 | left: f64, |
| 46 | width: f64, |
| 47 | } |
| 48 | |
| 49 | /// Compiles `src` as a lone `/doc/main.typ`, returning every placed text run and the content block's left |
| 50 | /// edge and width. |
| 51 | fn compile(src: &str) -> Outcome<(Vec<Run>, Geom)> { |
| 52 | let _turn = match VFS.lock() { |
| 53 | Ok(g) => g, |
| 54 | Err(p) => p.into_inner(), |
| 55 | }; |
| 56 | let main = PathBuf::from("/doc/main.typ"); |
| 57 | let mut files: HashMap<PathBuf, Vec<u8>> = HashMap::new(); |
| 58 | files.insert(main.clone(), src.as_bytes().to_vec()); |
| 59 | res!(vfs::install(files)); |
| 60 | let set = Arc::new(res!(fonts::libertinus())); |
| 61 | let result = compile::assemble(&main, || Ok(set.clone())) |
| 62 | .and_then(|(a, _, _)| compile::author_and_run(a)); |
| 63 | let _ = vfs::clear(); |
| 64 | let rendered = res!(result); |
| 65 | let geom = Geom { |
| 66 | left: rendered.geom.content_left().to_pt(), |
| 67 | width: rendered.geom.content_width().to_pt(), |
| 68 | }; |
| 69 | let mut runs = Vec::new(); |
| 70 | for page in &rendered.out.pages { |
| 71 | for placed in &page.frame.placed { |
| 72 | if let PlacedKind::Text(shaped) = &placed.kind { |
| 73 | runs.push(Run { |
| 74 | page: page.number, |
| 75 | x0: placed.x.to_pt(), |
| 76 | x1: placed.x.to_pt() + placed.dims.width.to_pt(), |
| 77 | y: placed.y.to_pt(), |
| 78 | src: shaped.source().to_string(), |
| 79 | }); |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | Ok((runs, geom)) |
| 84 | } |
| 85 | |
| 86 | /// `n` paragraphs of placeholder prose, each tagged with a word the test can find it by. |
| 87 | fn prose(n: usize, tag: &str) -> String { |
| 88 | let mut s = String::new(); |
| 89 | for i in 0..n { |
| 90 | s.push_str(&fmt!("{}{}\n#lorem(110)\n\n", tag, i)); |
| 91 | } |
| 92 | s |
| 93 | } |
| 94 | |
| 95 | /// The runs on `page` whose source contains `word`. |
| 96 | fn find<'a>(runs: &'a [Run], word: &str) -> Vec<&'a Run> { |
| 97 | runs.iter().filter(|r| r.src.contains(word)).collect() |
| 98 | } |
| 99 | |
| 100 | /// The first run carrying `word`, or an error naming it. |
| 101 | fn first<'a>(runs: &'a [Run], word: &str) -> Outcome<&'a Run> { |
| 102 | match runs.iter().find(|r| r.src.contains(word)) { |
| 103 | Some(r) => Ok(r), |
| 104 | None => Err(err!("No placed run carries {:?}.", word; Test, Missing)), |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // A quarter point, for positions computed in scaled integers and read back as points. |
| 109 | const TOL: f64 = 0.25; |
| 110 | |
| 111 | /// Two columns 4% of the measure apart, filled left then right: page one's body runs all lie within one |
| 112 | /// column or the other, both columns carry text, and the prose that opens the right column follows the |
| 113 | /// prose that ends the left. |
| 114 | #[test] |
| 115 | fn two_columns_fill_left_then_right() -> Outcome<()> { |
| 116 | let src = fmt!("#set page(columns: 2)\n\n{}", prose(12, "Para")); |
| 117 | let (runs, g) = res!(compile(&src)); |
| 118 | let gutter = g.width * 0.04; |
| 119 | let col_w = (g.width - gutter) / 2.0; |
| 120 | let right = g.left + col_w + gutter; |
| 121 | // The body runs of page one, the folio at its foot aside. |
| 122 | let foot = runs.iter().filter(|r| r.page == 1).map(|r| r.y).fold(0.0f64, f64::max); |
| 123 | let body: Vec<&Run> = runs.iter().filter(|r| r.page == 1 && r.y < foot - TOL).collect(); |
| 124 | assert!(!body.is_empty()); |
| 125 | let mut left_n = 0; |
| 126 | let mut right_n = 0; |
| 127 | for r in &body { |
| 128 | if r.x1 <= g.left + col_w + TOL { |
| 129 | left_n += 1; |
| 130 | } else if r.x0 >= right - TOL && r.x1 <= g.left + g.width + TOL { |
| 131 | right_n += 1; |
| 132 | } else { |
| 133 | return Err(err!("A run straddles the gutter or overruns the page: {:?}.", r; Test, Mismatch)); |
| 134 | } |
| 135 | } |
| 136 | assert!(left_n > 20 && right_n > 20, "both columns carry text: {} left, {} right", left_n, right_n); |
| 137 | // The paragraph tags run in order down the left column and on into the right. |
| 138 | let tags: Vec<&Run> = body.iter().copied().filter(|r| r.src.starts_with("Para")).collect(); |
| 139 | assert!(tags.len() >= 2, "{:?}", tags); |
| 140 | let first_right = tags.iter().position(|r| r.x0 >= right - TOL); |
| 141 | match first_right { |
| 142 | Some(k) => assert!(tags[..k].iter().all(|r| r.x1 <= g.left + col_w + TOL), "{:?}", tags), |
| 143 | None => return Err(err!("No paragraph opened in the right column: {:?}.", tags; Test, Mismatch)), |
| 144 | } |
| 145 | // The flow ran on to a second page. |
| 146 | assert!(runs.iter().any(|r| r.page == 2), "twelve paragraphs fill more than one page of two columns"); |
| 147 | Ok(()) |
| 148 | } |
| 149 | |
| 150 | /// A document naming no columns sets its lines across the whole measure. |
| 151 | #[test] |
| 152 | fn one_column_is_the_ordinary_page() -> Outcome<()> { |
| 153 | let (runs, g) = res!(compile(&prose(3, "Para"))); |
| 154 | let widest = runs.iter().map(|r| r.x1).fold(0.0f64, f64::max); |
| 155 | assert!(widest > g.left + g.width * 0.9, "a justified line reaches the right margin: {}", widest); |
| 156 | Ok(()) |
| 157 | } |
| 158 | |
| 159 | /// A `#place(top, scope: "parent", float: true)` at the start of a two-column page spans both columns at |
| 160 | /// the page top, and both columns start below it. |
| 161 | #[test] |
| 162 | fn a_parent_float_spans_both_columns_above_them() -> Outcome<()> { |
| 163 | let src = fmt!( |
| 164 | "#set page(columns: 2)\n\n#place(top, scope: \"parent\", float: true)[Spanning banner words that run on across the \ |
| 165 | whole page width well past the gutter between the two columns of the body below.]\n\n{}", |
| 166 | prose(8, "Para")); |
| 167 | let (runs, g) = res!(compile(&src)); |
| 168 | let gutter = g.width * 0.04; |
| 169 | let col_w = (g.width - gutter) / 2.0; |
| 170 | let banner = res!(first(&runs, "Spanning")); |
| 171 | let line: Vec<&Run> = runs.iter().filter(|r| r.page == 1 && (r.y - banner.y).abs() < TOL).collect(); |
| 172 | let reach = line.iter().map(|r| r.x1).fold(0.0f64, f64::max); |
| 173 | assert!(reach > g.left + col_w + gutter, "the banner line runs past the gutter: {:?}", line); |
| 174 | // The banner's last line, and the prose of both columns below it. |
| 175 | let last = res!(first(&runs, "below.")); |
| 176 | assert_eq!((banner.page, last.page), (1, 1)); |
| 177 | let para0 = res!(first(&runs, "Para0")); |
| 178 | assert!(para0.page == 1 && para0.y > last.y, "the column text starts below the banner: {:?} vs {:?}", para0, last); |
| 179 | // Everything else on the page lies below the banner: nothing shares its band but its own lines. |
| 180 | let below: Vec<&Run> = runs.iter().filter(|r| r.page == 1 && r.y > last.y + TOL).collect(); |
| 181 | let words = "Spanning banner words that run on across the whole page width well past the gutter between the \ |
| 182 | two columns of the body below."; |
| 183 | for r in runs.iter().filter(|r| r.page == 1 && r.y <= last.y + TOL) { |
| 184 | assert!(words.contains(r.src.trim()), "only the banner's own lines sit in its band: {:?}", r); |
| 185 | } |
| 186 | assert!(below.iter().any(|r| r.x0 >= g.left + col_w + gutter - TOL), "both columns run beneath it"); |
| 187 | Ok(()) |
| 188 | } |
| 189 | |
| 190 | /// A parent-scoped float met after the columns already carry text is still seated at the top of that |
| 191 | /// page: the page is relaid beneath it, so the text that preceded it in the source sits below it. |
| 192 | #[test] |
| 193 | fn a_parent_float_met_mid_page_relays_the_page() -> Outcome<()> { |
| 194 | let src = fmt!( |
| 195 | "#set page(columns: 2)\n\nOpening words.\n\n#place(top, scope: \"parent\", float: true)[Spanning banner words that \ |
| 196 | run on across the whole page width well past the gutter between the two columns.]\n\n{}", |
| 197 | prose(8, "Para")); |
| 198 | let (runs, _) = res!(compile(&src)); |
| 199 | let banner = res!(first(&runs, "Spanning")); |
| 200 | let opening = res!(first(&runs, "Opening")); |
| 201 | assert_eq!(banner.page, 1, "the banner is seated on the page it was met on"); |
| 202 | assert_eq!(opening.page, 1); |
| 203 | assert!(opening.y > banner.y, "the text before the banner in the source is relaid below it: {:?} {:?}", opening, banner); |
| 204 | Ok(()) |
| 205 | } |
| 206 | |
| 207 | /// A column-scoped float (a figure placed at the top, Typst's default scope) met in the right column |
| 208 | /// settles at the top of that column: its runs stay within the right column's bounds, above its prose. |
| 209 | #[test] |
| 210 | fn a_column_float_stays_in_its_column() -> Outcome<()> { |
| 211 | let src = fmt!( |
| 212 | "#set page(columns: 2)\n\nLeft words.\n\n#colbreak()\n\n{}#figure(table(columns: 2, [Alpha], [Beta]), placement: top, \ |
| 213 | caption: [Tabled])\n\n{}", |
| 214 | prose(1, "Para"), prose(1, "More")); |
| 215 | let (runs, g) = res!(compile(&src)); |
| 216 | let gutter = g.width * 0.04; |
| 217 | let col_w = (g.width - gutter) / 2.0; |
| 218 | let right = g.left + col_w + gutter; |
| 219 | let alpha = res!(first(&runs, "Alpha")); |
| 220 | let cap = res!(first(&runs, "Tabled")); |
| 221 | let in_left = |r: &Run| r.x1 <= g.left + col_w + TOL; |
| 222 | let in_right = |r: &Run| r.x0 >= right - TOL; |
| 223 | assert!(in_right(alpha) && in_right(cap), "the figure sits within the right column: {:?} {:?}", alpha, cap); |
| 224 | assert!(!in_right(res!(first(&runs, "Left"))), "the left column keeps its own text"); |
| 225 | // It rose to the top of its column, above that column's prose. |
| 226 | let same_col: Vec<&Run> = runs.iter() |
| 227 | .filter(|r| r.page == alpha.page && in_left(r) == in_left(alpha) && r.src.starts_with("Para")) |
| 228 | .collect(); |
| 229 | assert!(same_col.iter().all(|r| r.y > alpha.y), "the figure tops its column: {:?} vs {:?}", alpha, same_col); |
| 230 | Ok(()) |
| 231 | } |
| 232 | |
| 233 | /// `#colbreak()` moves the flow to the next column: the text after it opens the right column of the same |
| 234 | /// page, level with the left column's top. |
| 235 | #[test] |
| 236 | fn colbreak_moves_to_the_next_column() -> Outcome<()> { |
| 237 | let src = "#set page(columns: 2)\n\nLeft words.\n\n#colbreak()\n\nRight words.\n"; |
| 238 | let (runs, g) = res!(compile(src)); |
| 239 | let gutter = g.width * 0.04; |
| 240 | let col_w = (g.width - gutter) / 2.0; |
| 241 | let left = res!(first(&runs, "Left")); |
| 242 | let right = res!(first(&runs, "Right")); |
| 243 | assert_eq!((left.page, right.page), (1, 1)); |
| 244 | assert!(right.x0 >= g.left + col_w + gutter - TOL, "{:?}", right); |
| 245 | assert!((right.y - left.y).abs() < TOL, "both columns open at the same top: {:?} {:?}", left, right); |
| 246 | // Without columns the break turns the page instead. |
| 247 | let (flat, _) = res!(compile("Left words.\n\n#colbreak()\n\nRight words.\n")); |
| 248 | assert_eq!(res!(first(&flat, "Right")).page, 2, "a column break on a page of one column breaks the page"); |
| 249 | assert!(find(&flat, "Left").iter().all(|r| r.page == 1)); |
| 250 | Ok(()) |
| 251 | } |
| 252 | |
| 253 | /// `#set columns(gutter: 30pt)` parts the columns by that length. |
| 254 | #[test] |
| 255 | fn the_gutter_is_the_set_length() -> Outcome<()> { |
| 256 | let src = "#set page(columns: 2)\n#set columns(gutter: 30pt)\n\nLeft words.\n\n#colbreak()\n\nRight words.\n"; |
| 257 | let (runs, g) = res!(compile(src)); |
| 258 | let col_w = (g.width - 30.0) / 2.0; |
| 259 | let right = res!(first(&runs, "Right")); |
| 260 | assert!((right.x0 - (g.left + col_w + 30.0)).abs() < 0.5, "{:?} vs {}", right, g.left + col_w + 30.0); |
| 261 | Ok(()) |
| 262 | } |
| 263 | |
| 264 | /// A scope setting its own page columns -- an included chapter's `#set page(columns: 2)` -- starts them on a |
| 265 | /// fresh page, sets its blocks at the column measure, and returns to one column on another fresh page when |
| 266 | /// it ends, as Typst's `set page` inside a scope does. |
| 267 | #[test] |
| 268 | fn a_scoped_column_layout_turns_the_page_both_ways() -> Outcome<()> { |
| 269 | use oxedyne_fe2o3_austenite::doc::{self, Block}; |
| 270 | use oxedyne_fe2o3_austenite::driver::{self, Config}; |
| 271 | use oxedyne_fe2o3_austenite::font::FontMetrics; |
| 272 | use oxedyne_fe2o3_austenite::fonts::FaceResolver; |
| 273 | use oxedyne_fe2o3_austenite::page::PageGeometry; |
| 274 | use oxedyne_fe2o3_austenite::theme::{Theme, ThemePatch}; |
| 275 | use oxedyne_fe2o3_font::{face::Role, shape::Dir}; |
| 276 | |
| 277 | let words = |tag: &str| fmt!("{} {}", tag, "lorem ipsum dolor sit amet consectetur ".repeat(30)); |
| 278 | let mut patch = ThemePatch::default(); |
| 279 | patch.page.columns = Some(2); |
| 280 | let blocks = vec![ |
| 281 | Block::paragraph(words("Dog")), |
| 282 | Block::Scoped { patch, blocks: vec![Block::paragraph(words("Cat")), Block::paragraph(words("Owl"))] }, |
| 283 | Block::paragraph(words("Emu")), |
| 284 | ]; |
| 285 | let fonts = Arc::new(res!(fonts::libertinus())); |
| 286 | let geom = PageGeometry::a4(); |
| 287 | let style = Theme::default(); |
| 288 | let (document, _) = res!(doc::author(fonts.clone(), geom, &style, &FaceResolver::default(), &blocks, None, None)); |
| 289 | let metrics = FontMetrics::new(fonts, Role::Body, Dir::Ltr, style.text.body_size); |
| 290 | let out = res!(driver::run(&document, &metrics, Config::default())); |
| 291 | let mut runs: Vec<Run> = Vec::new(); |
| 292 | for page in &out.pages { |
| 293 | for placed in &page.frame.placed { |
| 294 | if let PlacedKind::Text(shaped) = &placed.kind { |
| 295 | runs.push(Run { |
| 296 | page: page.number, |
| 297 | x0: placed.x.to_pt(), |
| 298 | x1: placed.x.to_pt() + placed.dims.width.to_pt(), |
| 299 | y: placed.y.to_pt(), |
| 300 | src: shaped.source().to_string(), |
| 301 | }); |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | let left = geom.content_left().to_pt(); |
| 306 | let width = geom.content_width().to_pt(); |
| 307 | let col_w = (width - width * 0.04) / 2.0; |
| 308 | let before = res!(first(&runs, "Dog")); |
| 309 | let inside = res!(first(&runs, "Cat")); |
| 310 | let after = res!(first(&runs, "Emu")); |
| 311 | assert_eq!((before.page, inside.page, after.page), (1, 2, 3), "the scope opens and closes on fresh pages"); |
| 312 | for r in runs.iter().filter(|r| r.page == 2) { |
| 313 | assert!(r.x1 <= left + col_w + TOL || r.x0 >= left + col_w + width * 0.04 - TOL, |
| 314 | "the scope's text keeps to its columns: {:?}", r); |
| 315 | } |
| 316 | let widest_after = runs.iter().filter(|r| r.page == 3).map(|r| r.x1).fold(0.0f64, f64::max); |
| 317 | assert!(widest_after > left + width * 0.9, "after the scope the page is one column again: {}", widest_after); |
| 318 | Ok(()) |
| 319 | } |