use super::common::*;
fn long_body(n: usize) -> String {
let mut out = String::new();
for i in 0..n {
out.push_str(&format!(
"## Section {i}\n\nLorem ipsum dolor sit amet, consectetur adipiscing \
elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut \
aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in \
voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\n"
));
}
out
}
fn td_xs(bytes: &[u8]) -> Vec<f32> {
let decoded = scan(bytes);
let s = String::from_utf8_lossy(&decoded);
let mut xs = Vec::new();
for line in s.lines() {
let trimmed = line.trim_end();
if !trimmed.ends_with(" Td") {
continue;
}
let mut it = trimmed.split_whitespace();
let x = it.next();
let y = it.next();
let op = it.next();
if op != Some("Td") {
continue;
}
if let (Some(xs_), Some(_)) = (x.and_then(|t| t.parse::<f32>().ok()), y) {
xs.push(xs_);
}
}
xs
}
fn x_clusters(mut xs: Vec<f32>, bin_pt: f32) -> Vec<f32> {
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mut clusters: Vec<(f32, usize)> = Vec::new(); for x in xs {
if let Some((sum, n)) = clusters.last_mut() {
if (x - *sum / *n as f32).abs() < bin_pt {
*sum += x;
*n += 1;
continue;
}
}
clusters.push((x, 1));
}
clusters.into_iter().map(|(s, n)| s / n as f32).collect()
}
fn distinct_column_edges(bytes: &[u8]) -> usize {
x_clusters(td_xs(bytes), 12.0).len()
}
#[test]
fn default_render_is_single_column() {
let bytes = render(&long_body(8), "");
let edges = distinct_column_edges(&bytes);
assert!(
edges <= 3,
"single-column render should have at most a handful of x edges \
(left margin + padding nests), got {edges}"
);
}
#[test]
fn two_columns_emits_text_in_both() {
let bytes = render(
&long_body(10),
r##"
[page]
columns = 2
column_gap_mm = 8
"##,
);
let xs = td_xs(&bytes);
assert!(!xs.is_empty(), "expected some Td ops in the body");
let min_x = xs.iter().cloned().fold(f32::INFINITY, f32::min);
let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(
max_x - min_x > 200.0,
"expected Td x to span both columns (min={min_x:.1}, max={max_x:.1})"
);
}
#[test]
fn two_columns_creates_at_least_two_column_clusters() {
let bytes = render(
&long_body(10),
r##"
[page]
columns = 2
column_gap_mm = 8
"##,
);
assert!(
distinct_column_edges(&bytes) >= 2,
"two-column layout should produce at least two column x edges"
);
}
#[test]
fn paragraph_after_column_break_uses_new_column_geometry() {
let bytes = render(
&long_body(12),
r##"
[page]
columns = 2
column_gap_mm = 8
"##,
);
let xs = td_xs(&bytes);
let in_col1 = xs.iter().filter(|&&x| x > 250.0).count();
assert!(
in_col1 > 1,
"expected several Td ops in column 1 after the break, got {in_col1} \
(xs={xs:?})"
);
}
#[test]
fn three_columns_emits_three_distinct_clusters() {
let bytes = render(
&long_body(18),
r##"
[page]
columns = 3
column_gap_mm = 6
"##,
);
assert!(
distinct_column_edges(&bytes) >= 3,
"three-column layout should produce at least three column x edges"
);
}
#[test]
fn four_columns_emits_four_distinct_clusters() {
let bytes = render(
&long_body(24),
r##"
[page]
columns = 4
column_gap_mm = 4
"##,
);
assert!(
distinct_column_edges(&bytes) >= 4,
"four-column layout should produce at least four column x edges"
);
}
#[test]
fn columns_above_four_clamp_to_four() {
let bytes = render(
&long_body(20),
r##"
[page]
columns = 99
column_gap_mm = 4
"##,
);
assert!(pdf_well_formed(&bytes));
assert!(
distinct_column_edges(&bytes) <= 4,
"columns=99 should be clamped to 4, but found more x clusters"
);
}
#[test]
fn columns_zero_clamps_to_single_column() {
let bytes = render(
&long_body(6),
r##"
[page]
columns = 0
"##,
);
assert!(pdf_well_formed(&bytes));
let xs = td_xs(&bytes);
let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(
max_x < 250.0,
"columns=0 should collapse to single-column (max Td x={max_x:.1})"
);
}
#[test]
fn negative_column_gap_does_not_break_geometry() {
let bytes = render(
&long_body(10),
r##"
[page]
columns = 2
column_gap_mm = -50.0
"##,
);
assert!(pdf_well_formed(&bytes));
assert!(distinct_column_edges(&bytes) >= 2);
}
#[test]
fn images_tables_and_code_blocks_survive_a_column_break() {
let md = r##"
# Heading
Body paragraph with enough text to do useful work in the first column.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
| Name | Role | Count |
| ----- | --------- | ----- |
| Alice | author | 12 |
| Bob | reviewer | 7 |
```rust
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
```
> A quoted block that wraps over a few lines so that subsequent
> blocks have somewhere to spill into.
Closing paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
"##;
let mut doc = String::new();
for _ in 0..6 {
doc.push_str(md);
}
let bytes = render(
&doc,
r##"
[page]
columns = 2
column_gap_mm = 8
"##,
);
assert!(pdf_well_formed(&bytes));
assert!(
distinct_column_edges(&bytes) >= 2,
"mixed-block doc should still flow into a second column"
);
}
fn td_ys(bytes: &[u8]) -> Vec<f32> {
let decoded = scan(bytes);
let s = String::from_utf8_lossy(&decoded);
let mut ys = Vec::new();
for line in s.lines() {
let trimmed = line.trim_end();
if !trimmed.ends_with(" Td") {
continue;
}
let mut it = trimmed.split_whitespace();
let _x = it.next();
let y = it.next();
let op = it.next();
if op != Some("Td") {
continue;
}
if let Some(y) = y.and_then(|t| t.parse::<f32>().ok()) {
ys.push(y);
}
}
ys
}
#[test]
fn table_in_narrow_column_stays_within_body_right_edge() {
let md = r##"# Header
Some leading text.
| Column One | Column Two | Column Three | Column Four | Column Five | Column Six |
| ---------- | ---------- | ------------ | ----------- | ----------- | ---------- |
| a | b | c | d | e | f |
| 1 | 2 | 3 | 4 | 5 | 6 |
Trailing paragraph.
"##;
let bytes = render(
md,
r##"
[page]
columns = 4
column_gap_mm = 4
"##,
);
let xs = td_xs(&bytes);
let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(
max_x < 567.0,
"no Td origin should land past the body right edge \
(max_x={max_x:.1})"
);
}
#[test]
fn table_cells_dont_overflow_their_column() {
let md = r##"## Header
Body filler so the table doesn't end up at page-top alone.
| Column One | Column Two | Column Three | Column Four | Column Five | Column Six |
| ---------- | ---------- | ------------ | ----------- | ----------- | ---------- |
| a | b | c | d | e | f |
More text.
"##;
let bytes = render(
md,
r##"
[page]
columns = 4
column_gap_mm = 4
"##,
);
let xs = td_xs(&bytes);
let col_ranges: &[(f32, f32)] = &[
(45.0, 168.0), (178.0, 301.0), (311.0, 434.0), (444.0, 567.0), ];
for x in &xs {
let in_any = col_ranges
.iter()
.any(|(l, r)| *x >= *l - 0.5 && *x <= *r + 0.5);
assert!(
in_any,
"Td origin x={x:.1} fell into a column gap or outside the body"
);
}
}
#[test]
fn multicolumn_collapses_first_block_top_margin() {
let md = "# Title\n\nLong paragraph. ".repeat(40);
let big_margin_cfg = r##"
[headings.h1]
margin_before_pt = 30.0
[page]
columns = 2
column_gap_mm = 8
"##;
let small_margin_cfg = r##"
[headings.h1]
margin_before_pt = 30.0
"##;
let multi = render(&md, big_margin_cfg);
let single = render(&md, small_margin_cfg);
let topmost_multi = td_ys(&multi)
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max);
let topmost_single = td_ys(&single)
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max);
let delta = topmost_multi - topmost_single;
assert!(
delta > 20.0,
"multi-column first block should sit ~30pt higher \
(topmost_multi={topmost_multi:.1}, \
topmost_single={topmost_single:.1}, delta={delta:.1})"
);
}
#[test]
fn singlecolumn_preserves_first_block_top_margin() {
let md = "# Title\n\nBody text. ".repeat(10);
let no_margin = render(
&md,
r##"
[headings.h1]
margin_before_pt = 0.0
"##,
);
let big_margin = render(
&md,
r##"
[headings.h1]
margin_before_pt = 40.0
"##,
);
let topmost_no = td_ys(&no_margin)
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max);
let topmost_big = td_ys(&big_margin)
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max);
assert!(
topmost_no > topmost_big,
"single-column must honor margin_before_pt on the first block \
(topmost_no={topmost_no:.1}, topmost_big={topmost_big:.1})"
);
}
#[test]
fn definition_list_spanning_columns_rebases_indents() {
let mut md = String::from("# DefList\n\nLead.\n\n");
for i in 1..=40 {
md.push_str(&format!(
"Term {i}\n: Body number {i}. Lorem ipsum dolor sit amet, \
consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore \
et dolore magna aliqua. Ut enim ad minim veniam.\n\n"
));
}
let bytes = render(
&md,
r##"
[page]
columns = 2
column_gap_mm = 8
"##,
);
let xs = td_xs(&bytes);
let in_col1 = xs.iter().filter(|&&x| x > 300.0).count();
assert!(
in_col1 >= 20,
"expected the deflist to fill col 1 after the column break \
(got only {in_col1} Td origins past 300pt — saved indents \
were not rebased to the current column)"
);
}
#[test]
fn wide_display_math_in_narrow_column_renders() {
let md = r##"# Wide math
Lead.
$$
\int_{-\infty}^{\infty} e^{-x^2 + 2ax - b} \, dx \cdot \sum_{n=1}^{\infty} \frac{(-1)^n}{n^2} \cdot \prod_{k=0}^{\infty} \left(1 - \frac{z^2}{(k\pi)^2}\right) = \frac{\sqrt{\pi} \cdot e^{a^2 - b}}{6} \cdot \frac{\sin(z)}{z}
$$
Trailing text at the column edge.
"##;
let bytes = render(
md,
r##"
[page]
columns = 4
column_gap_mm = 4
"##,
);
assert!(pdf_well_formed(&bytes));
let xs = td_xs(&bytes);
let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(
max_x < 200.0,
"wide math block must not push following text past col 0 \
(max Td x = {max_x:.1})"
);
}
#[test]
fn bare_url_longer_than_column_wraps_at_char_boundary() {
let md = r##"# Long URL
https://example.com/very/long/path/that/is/wider/than/any/single/column/and/should/wrap/at/character/boundaries/instead/of/overflowing?query=true&another=more
Trailing text.
"##;
let bytes = render(
md,
r##"
[page]
columns = 4
column_gap_mm = 4
"##,
);
let xs = td_xs(&bytes);
let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(
max_x < 200.0,
"bare URL must wrap inside col 0 (max Td x = {max_x:.1}, \
col 0 right ≈ 168pt)"
);
}