use super::*;
#[test]
fn test_table_cell_with_multiple_paragraphs() {
let multi_para_cell = TableCell {
content: vec![
Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "First para".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
}),
Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Second para".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
}),
],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![multi_para_cell],
height: None,
}],
column_widths: vec![200.0],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("First para"),
"Expected First para in: {result}"
);
assert!(
result.contains("Second para"),
"Expected Second para in: {result}"
);
}
#[test]
fn test_table_cell_simple_list_uses_compact_fixed_text_layout() {
let list = List {
kind: ListKind::Unordered,
items: vec![
ListItem {
content: vec![Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "First item".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
}],
level: 0,
start_at: None,
},
ListItem {
content: vec![Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Second item".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
}],
level: 0,
start_at: None,
},
],
level_styles: BTreeMap::new(),
};
let cell = TableCell {
content: vec![Block::List(list)],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![200.0],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#stack(dir: ttb"),
"Expected compact stack-based list layout in: {result}"
);
assert!(
!result.contains("#list("),
"Compact table-cell lists should not use Typst list layout in: {result}"
);
assert!(result.contains("First item"));
assert!(result.contains("Second item"));
}
#[test]
fn test_table_cell_simple_list_treats_default_and_explicit_left_as_same_style() {
let list = List {
kind: ListKind::Unordered,
items: vec![
ListItem {
content: vec![Paragraph {
style: ParagraphStyle {
alignment: Some(Alignment::Left),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "First item".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
}],
level: 0,
start_at: None,
},
ListItem {
content: vec![Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Second item".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
}],
level: 0,
start_at: None,
},
],
level_styles: BTreeMap::new(),
};
let cell = TableCell {
content: vec![Block::List(list)],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![200.0],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#stack(dir: ttb"),
"Expected compact stack-based list layout when only left-alignment explicitness differs: {result}"
);
assert!(
!result.contains("#list("),
"Equivalent left-alignment styles should not force Typst list layout in: {result}"
);
}
#[test]
fn test_table_cell_compact_list_adds_inter_item_spacing_from_line_spacing() {
let list = List {
kind: ListKind::Unordered,
items: vec![
ListItem {
content: vec![Paragraph {
style: ParagraphStyle {
line_spacing: Some(LineSpacing::Proportional(1.5)),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "First item".to_string(),
style: TextStyle {
font_size: Some(24.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
}],
level: 0,
start_at: None,
},
ListItem {
content: vec![Paragraph {
style: ParagraphStyle {
line_spacing: Some(LineSpacing::Proportional(1.5)),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Second item".to_string(),
style: TextStyle {
font_size: Some(24.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
}],
level: 0,
start_at: None,
},
],
level_styles: BTreeMap::new(),
};
let cell = TableCell {
content: vec![Block::List(list)],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![200.0],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#set par(leading: 12pt)"),
"Expected paragraph leading derived from PPT line spacing in: {result}"
);
assert!(
result.contains("#stack(dir: ttb, spacing: 12pt"),
"Compact table-cell lists should add inter-item spacing derived from PPT line spacing in: {result}"
);
}
#[test]
fn test_east_asian_table_cell_snaps_to_the_document_grid() {
let Some((ascender, _descender, word_pitch_em)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return; };
let font_size: f64 = 10.0;
let grid_em: f64 = 18.0 / font_size;
let top_em: f64 = ascender + 0.15 * word_pitch_em;
let bottom_em: f64 = grid_em - top_em;
let ungridded_bottom_em: f64 = 1.3 * word_pitch_em - top_em;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "회의 안건".to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(font_size),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![200.0],
..Table::default()
};
let mut page = match make_flow_page(vec![Block::Table(table)]) {
Page::Flow(flow) => flow,
_ => unreachable!(),
};
page.line_grid_pitch = Some(18.0);
page.line_grid_snaps_lines = true;
let doc = make_doc(vec![Page::Flow(page)]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(&format!(
"top-edge: {}em, bottom-edge: -{}em",
format_f64(top_em),
format_f64(bottom_em)
)),
"Korean cell must fill the 18pt grid line box: {result}"
);
assert!(
result.contains("#set par(leading: 0pt)"),
"cell line box uses zero leading (box already equals the full line): {result}"
);
assert!(
!result.contains(&format!(
"bottom-edge: -{}em",
format_f64(ungridded_bottom_em)
)),
"Korean cell must take the grid slot, not its own East Asian line: {result}"
);
}
#[test]
fn test_latin_table_cell_uses_natural_line_height() {
let Some((ascender, _descender, word_pitch_em)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return;
};
let font_size: f64 = 10.0;
let top_em: f64 = ascender;
let bottom_em: f64 = word_pitch_em - top_em;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Agenda".to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(font_size),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![200.0],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(&format!(
"top-edge: {}em, bottom-edge: -{}em",
format_f64(top_em),
format_f64(bottom_em)
)),
"Latin cell must fill the full hhea line box: {result}"
);
}
#[test]
fn mixed_script_row_shares_one_line_box() {
let Some((ascender, _descender, word_pitch_em)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return; };
let font_size: f64 = 10.0;
let grid_em: f64 = 18.0 / font_size;
let top_em: f64 = ascender + 0.15 * word_pitch_em;
let bottom_em: f64 = grid_em - top_em;
let make_cell = |text: &str| TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: text.to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(font_size),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![make_cell("2024년 1월"), make_cell("380")],
height: None,
}],
column_widths: vec![120.0, 80.0],
..Table::default()
};
let mut page = match make_flow_page(vec![Block::Table(table)]) {
Page::Flow(flow) => flow,
_ => unreachable!(),
};
page.line_grid_pitch = Some(18.0);
page.line_grid_snaps_lines = true;
let doc = make_doc(vec![Page::Flow(page)]);
let result = generate_typst(&doc).unwrap().source;
let grid_box = format!(
"top-edge: {}em, bottom-edge: -{}em",
format_f64(top_em),
format_f64(bottom_em)
);
assert_eq!(
result.matches(&grid_box).count(),
2,
"both cells in the row must take the same grid line box: {result}"
);
}
#[test]
fn latin_only_row_under_a_grid_keeps_the_font_line() {
let Some((ascender, _descender, word_pitch_em)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return;
};
let font_size: f64 = 10.0;
let hhea_top_em: f64 = ascender;
let hhea_bottom_em: f64 = word_pitch_em - hhea_top_em;
let make_cell = |text: &str| TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: text.to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(font_size),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![make_cell("Aug 3"), make_cell("380")],
height: None,
}],
column_widths: vec![120.0, 80.0],
..Table::default()
};
let mut page = match make_flow_page(vec![Block::Table(table)]) {
Page::Flow(flow) => flow,
_ => unreachable!(),
};
page.line_grid_pitch = Some(18.0);
page.line_grid_snaps_lines = true;
let doc = make_doc(vec![Page::Flow(page)]);
let result = generate_typst(&doc).unwrap().source;
assert_eq!(
result
.matches(&format!(
"top-edge: {}em, bottom-edge: -{}em",
format_f64(hhea_top_em),
format_f64(hhea_bottom_em)
))
.count(),
2,
"a Latin-only row must keep the font's hhea line under a grid: {result}"
);
}
#[test]
fn grid_cell_absorbs_space_after_into_the_line_box() {
let Some((ascender, _descender, word_pitch_em)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return; };
let font_size: f64 = 10.0;
let grid_em: f64 = 18.0 / font_size;
let top_em: f64 = ascender + 0.15 * word_pitch_em;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
space_after: Some(1.5),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "2024년 1월".to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(font_size),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![160.0],
..Table::default()
};
let mut page = match make_flow_page(vec![Block::Table(table)]) {
Page::Flow(flow) => flow,
_ => unreachable!(),
};
page.line_grid_pitch = Some(18.0);
page.line_grid_snaps_lines = true;
let doc = make_doc(vec![Page::Flow(page)]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(&format!(
"top-edge: {}em, bottom-edge: -{}em",
format_f64(top_em),
format_f64(grid_em - top_em)
)),
"line plus w:after should snap to one 18pt grid line: {result}"
);
assert!(
!result.contains("#v(1.5pt)"),
"the absorbed gap must not also be emitted after the runs: {result}"
);
}
#[test]
fn ungridded_cell_still_emits_space_after() {
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
space_after: Some(1.5),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Aug 3".to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(10.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})],
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![160.0],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#v(1.5pt)"),
"an unsnapped cell keeps its declared gap: {result}"
);
}
#[test]
fn cell_border_width_joins_the_inset() {
let border = CellBorder {
top: Some(BorderSide {
width: 0.5,
color: Color::new(0, 0, 0),
style: BorderLineStyle::Solid,
}),
bottom: Some(BorderSide {
width: 0.5,
color: Color::new(0, 0, 0),
style: BorderLineStyle::Solid,
}),
left: None,
right: None,
};
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Aug 3".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
border: Some(border),
padding: Some(Insets {
top: 3.5,
right: 5.0,
bottom: 3.5,
left: 5.0,
}),
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![160.0],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("top: 3.75pt") && result.contains("bottom: 3.75pt"),
"half of each 0.5pt border should join the 3.5pt inset: {result}"
);
}