use pdfboss_style::{Align, Edges, Element, TextStyle, Theme};
use pdfboss_write::Color;
use crate::block::{CellAlign, Run};
use crate::layout::{frag_items, styled_runs, Engine, Item, BASELINE};
use crate::report::Report;
use crate::wrap::{wrap, LineBox};
use crate::Error;
const BORDER_WIDTH: f32 = 0.5;
const BORDER_COLOR: Color = Color::Gray(0.6);
#[allow(clippy::too_many_arguments)]
pub(crate) fn place_table(
engine: &mut Engine,
aligns: &[CellAlign],
head: &[Vec<Run>],
rows: &[Vec<Vec<Run>>],
base: &TextStyle,
left: f32,
right: f32,
report: &mut Report,
) -> Result<(), Error> {
let theme = engine.theme;
let th = base.apply(theme.declared(Element::Th));
let td = base.apply(theme.declared(Element::Td));
let th_padding = theme.padding(Element::Th);
let td_padding = theme.padding(Element::Td);
let th_background = theme.background(Element::Th);
let td_background = theme.background(Element::Td);
let margin = theme.margin(Element::Table);
let columns = aligns
.len()
.max(head.len())
.max(rows.iter().map(Vec::len).max().unwrap_or(0));
let mut widths = vec![0.0f32; columns];
for (j, width) in widths.iter_mut().enumerate() {
*width = natural(theme, head.get(j), &th, &th_padding)?;
}
for row in rows {
for (j, width) in widths.iter_mut().enumerate() {
*width = width.max(natural(theme, row.get(j), &td, &td_padding)?);
}
}
fit(&mut widths, right - left);
engine.gap(margin.top);
place_row(
engine,
head,
aligns,
&widths,
&th,
&th_padding,
th_background,
left,
report,
)?;
for row in rows {
place_row(
engine,
row,
aligns,
&widths,
&td,
&td_padding,
td_background,
left,
report,
)?;
}
engine.after(margin.bottom);
Ok(())
}
fn natural(
theme: &Theme,
runs: Option<&Vec<Run>>,
style: &TextStyle,
padding: &Edges,
) -> Result<f32, Error> {
let empty = Vec::new();
let mut discard = Report::default();
let styled = styled_runs(theme, runs.unwrap_or(&empty), style, &mut discard);
let lines = wrap(&styled, f32::MAX, style.size)?;
let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
Ok(width + padding.left + padding.right)
}
fn fit(widths: &mut [f32], available: f32) {
let total: f32 = widths.iter().sum();
if total <= available {
return;
}
let scale = available / total;
for width in widths.iter_mut() {
*width *= scale;
}
}
#[allow(clippy::too_many_arguments)]
fn place_row(
engine: &mut Engine,
cells: &[Vec<Run>],
aligns: &[CellAlign],
widths: &[f32],
style: &TextStyle,
padding: &Edges,
background: Option<Color>,
left: f32,
report: &mut Report,
) -> Result<(), Error> {
let theme = engine.theme;
let empty = Vec::new();
let mut wrapped: Vec<Vec<LineBox>> = Vec::with_capacity(widths.len());
for (j, width) in widths.iter().enumerate() {
let runs = cells.get(j).unwrap_or(&empty);
let styled = styled_runs(theme, runs, style, report);
let inner = (width - padding.left - padding.right).max(0.0);
wrapped.push(wrap(&styled, inner, style.size)?);
}
let content_h = wrapped
.iter()
.map(|lines| {
lines
.iter()
.map(|line| style.line_height * line.max_size)
.sum::<f32>()
})
.fold(0.0, f32::max);
let row_h = content_h + padding.top + padding.bottom;
engine.need(row_h);
let top = engine.y;
let mut x = left;
for (j, lines) in wrapped.into_iter().enumerate() {
let width = widths[j];
if let Some(color) = background {
engine.push(Item::Rect {
x,
y: top - row_h,
w: width,
h: row_h,
color,
});
}
engine.push(Item::Frame {
x,
y: top - row_h,
w: width,
h: row_h,
width: BORDER_WIDTH,
color: BORDER_COLOR,
});
let align = effective_align(
aligns.get(j).copied().unwrap_or(CellAlign::Default),
style.align,
);
let mut cursor = top - padding.top;
for line in &lines {
let h = style.line_height * line.max_size;
let baseline = cursor - BASELINE * line.max_size;
let origin = match align {
Align::Left => x + padding.left,
Align::Center => {
x + padding.left + (width - padding.left - padding.right - line.width) / 2.0
}
Align::Right => x + width - padding.right - line.width,
};
for item in frag_items(&line.frags, origin, baseline) {
engine.push(item);
}
cursor -= h;
}
x += width;
}
engine.y -= row_h;
engine.at_top = false;
Ok(())
}
fn effective_align(pipe: CellAlign, style_align: Align) -> Align {
match pipe {
CellAlign::Default => style_align,
CellAlign::Left => Align::Left,
CellAlign::Center => Align::Center,
CellAlign::Right => Align::Right,
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use pdfboss_style::Theme;
use pdfboss_write::{PageSize, Standard14};
use super::*;
use crate::block::parse_blocks;
use crate::layout::{layout, Item, LaidPage};
fn laid(md: &str, css: &str) -> Vec<LaidPage> {
laid_with_report(md, css).0
}
fn laid_with_report(md: &str, css: &str) -> (Vec<LaidPage>, Report) {
let theme = Theme::parse(css).unwrap();
let (blocks, _) = parse_blocks(md);
let mut report = Report::default();
let pages = layout(&blocks, &theme, PageSize::A4, Path::new("."), &mut report).unwrap();
(pages, report)
}
const MONO: &str =
"body { font-family: courier; font-size: 10pt; line-height: 1.0; margin: 100pt; }";
#[test]
fn columns_size_to_content_and_scale_to_fit() {
let pages = laid("| a | bbbb |\n|---|---|\n| c | d |\n", MONO);
let frames: Vec<(f32, f32)> = pages[0]
.items
.iter()
.filter_map(|item| match item {
Item::Frame { x, w, .. } => Some((*x, *w)),
_ => None,
})
.collect();
assert_eq!(frames.len(), 4);
let narrow = frames.iter().map(|(_, w)| *w).fold(f32::MAX, f32::min);
let wide = frames.iter().map(|(_, w)| *w).fold(0.0, f32::max);
assert!(wide > narrow, "content-sized columns: {frames:?}");
}
#[test]
fn columns_scale_down_proportionally_when_natural_widths_overflow() {
let long = "a".repeat(80);
let md = format!("| {long} | {long} |\n|---|---|\n| c | d |\n");
let pages = laid(&md, MONO);
let frames: Vec<f32> = pages[0]
.items
.iter()
.filter_map(|item| match item {
Item::Frame { w, .. } => Some(*w),
_ => None,
})
.collect();
assert_eq!(frames.len(), 4);
let available = 595.28 - 200.0;
let natural = 80.0 * 6.0 + 8.0;
let header_row_total: f32 = frames[..2].iter().sum();
assert!(
header_row_total <= available + 0.5,
"scaled widths sum to the available width: {frames:?}"
);
for width in &frames {
assert!(
*width < natural,
"each column narrower than its natural width: {width}"
);
}
}
#[test]
fn header_cells_are_bold_with_background() {
let pages = laid("| h |\n|---|\n| b |\n", MONO);
assert!(
pages[0]
.items
.iter()
.any(|i| matches!(i, Item::Rect { .. })),
"th background"
);
assert!(pages[0].items.iter().any(
|i| matches!(i, Item::Text { text, font, .. } if text == "h" && *font == Standard14::CourierBold)
));
}
#[test]
fn column_alignment_follows_the_pipes() {
let pages = laid("| L | R |\n|:--|--:|\n| aa | bb |\n", MONO);
let texts: Vec<(String, f32)> = pages[0]
.items
.iter()
.filter_map(|item| match item {
Item::Text { text, x, .. } => Some((text.clone(), *x)),
_ => None,
})
.collect();
let (_, left_x) = texts.iter().find(|(t, _)| t == "aa").unwrap();
let (_, right_x) = texts.iter().find(|(t, _)| t == "bb").unwrap();
assert!(*right_x > *left_x + 6.0, "right-aligned cell shifts right");
}
#[test]
fn rows_break_between_not_inside() {
let mut md = String::from("| h |\n|---|\n");
for i in 0..120 {
md.push_str(&format!("| row{i} |\n"));
}
let pages = laid(&md, MONO);
assert!(pages.len() >= 2);
}
#[test]
fn links_and_decorations_place_inside_cells() {
let pages = laid("| [d](https://x.y) |\n|---|\n| b |\n", MONO);
assert!(
pages[0]
.items
.iter()
.any(|i| matches!(i, Item::Link { uri, .. } if uri == "https://x.y")),
"cell link rect"
);
assert!(
pages[0]
.items
.iter()
.any(|i| matches!(i, Item::Stroke { .. })),
"cell link underline via the shared frag_items helper"
);
}
#[test]
fn unencodable_cell_characters_are_reported_once() {
let (_, report) = laid_with_report("| \u{4e2d} |\n|---|\n| b |\n", MONO);
assert_eq!(
report.replaced.get(&'\u{4e2d}'),
Some(&1),
"the column-sizing pass must not double-count sanitize replacements: {report:?}"
);
}
}