Oregami
Repositories/oxedyne/fe2o3

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
16use oxedyne_fe2o3_austenite::compile;
17use oxedyne_fe2o3_austenite::fonts;
18use oxedyne_fe2o3_austenite::page::PlacedKind;
19use oxedyne_fe2o3_austenite::vfs;
20
21use oxedyne_fe2o3_core::prelude::*;
22
23use std::collections::HashMap;
24use std::path::PathBuf;
25use std::sync::{
26 Arc,
27 Mutex,
28};
29
30// The source map is a process-wide global, so the cases take turns.
31static 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)]
35struct 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.
44struct 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.
51fn 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.
87fn 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`.
96fn 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.
101fn 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.
109const 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]
115fn 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]
152fn 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]
162fn 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]
193fn 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]
210fn 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]
236fn 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]
255fn 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]
268fn 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}