use kurbo::Rect;
use crate::lines::Line;
#[must_use]
pub fn layout(lines: &[Line], page: Rect) -> String {
if lines.is_empty() {
return String::new();
}
let mut sizes: Vec<f32> = lines
.iter()
.map(|l| l.font_size)
.filter(|s| *s > 0.0)
.collect();
sizes.sort_by(f32::total_cmp);
let body = f64::from(sizes.get(sizes.len() / 2).copied().unwrap_or(10.0)).max(1.0);
let cell_w = body * 0.5;
let row_h = body * 1.2;
let mut ordered: Vec<&Line> = lines.iter().collect();
ordered.sort_by(|a, b| {
b.bbox
.y0
.partial_cmp(&a.bbox.y0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
a.bbox
.x0
.partial_cmp(&b.bbox.x0)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
let mut rows: Vec<Vec<&Line>> = Vec::new();
for line in ordered {
match rows.last_mut() {
Some(row)
if row
.first()
.is_some_and(|f| (f.bbox.y0 - line.bbox.y0).abs() < body * 0.5) =>
{
row.push(line);
}
_ => rows.push(vec![line]),
}
}
let mut out = String::new();
let mut previous_bottom: Option<f64> = None;
for row in rows {
let top = row.first().map_or(0.0, |l| l.bbox.y1);
if let Some(prev) = previous_bottom {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "clamped to 0..=3 before the cast"
)]
let blank = ((prev - top) / row_h).floor().clamp(0.0, 3.0) as u32;
for _ in 0..blank {
out.push('\n');
}
}
previous_bottom = Some(row.first().map_or(top, |l| l.bbox.y0));
let mut text = String::new();
for line in row {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a non-negative cell count no wider than the page"
)]
let column = ((line.bbox.x0 - page.x0) / cell_w)
.round()
.clamp(0.0, 4096.0) as usize;
let width = text.chars().count();
if column > width {
text.push_str(&" ".repeat(column - width));
} else if width > 0 {
text.push(' ');
}
text.push_str(&line.text);
}
out.push_str(text.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::layout;
use crate::lines::Line;
use kurbo::Rect;
fn at(text: &str, x: f64, y: f64) -> Line {
Line {
text: text.to_owned(),
bbox: Rect::new(x, y, x + 50.0, y + 10.0),
font_size: 10.0,
bold: false,
bold_prefix: 0,
mono: false,
mcids: Vec::new(),
segments: Vec::new(),
}
}
#[test]
fn two_columns_share_a_row_and_a_gap_makes_a_blank_line() {
let page = Rect::new(0.0, 0.0, 612.0, 792.0);
let text = layout(
&[
at("below", 0.0, 60.0),
at("left", 0.0, 100.0),
at("right", 300.0, 100.0),
],
page,
);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0].trim_end(), format!("left{}right", " ".repeat(56)));
assert_eq!(lines[1], "");
assert_eq!(lines[2], "");
assert_eq!(lines[3], "below");
}
}