use super::*;
#[test]
fn test_data_bar_codegen() {
use crate::ir::DataBarInfo;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "50".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
data_bar: Some(DataBarInfo {
color: Color::new(0x63, 0x8E, 0xC6),
fill_pct: 50.0,
}),
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![100.0],
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("gradient.linear(rgb(99, 142, 198)"),
"DataBar should be a gradient in the bar color. Got: {}",
output.source,
);
assert!(
output.source.contains("width: 50%"),
"DataBar should contain 50% width. Got: {}",
output.source,
);
assert!(
output.source.contains("#place("),
"DataBar must be placed behind the value, not stacked above it. Got: {}",
output.source,
);
assert!(
!output.source.contains("rgb(240, 240, 240)"),
"Excel draws no gray track behind data bars. Got: {}",
output.source,
);
assert!(
!output.source.contains("height: 100%"),
"DataBar height must not be page-relative in auto-height rows. Got: {}",
output.source,
);
}
#[test]
fn test_data_bar_fixed_row_height_codegen() {
use crate::ir::DataBarInfo;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "75".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
data_bar: Some(DataBarInfo {
color: Color::new(0x63, 0x8E, 0xC6),
fill_pct: 75.0,
}),
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: Some(24.0),
}],
column_widths: vec![100.0],
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("height: 20pt"),
"DataBar in a fixed-height row should be inset from the row edges. Got: {}",
output.source,
);
assert!(
!output.source.contains("height: 100%"),
"DataBar height must never be page-relative. Got: {}",
output.source,
);
}
#[test]
fn test_icon_text_codegen() {
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "90".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
icon_text: Some("↑".to_string()),
icon_color: Some(Color::new(214, 85, 50)),
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![100.0],
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("↑"),
"Icon text should appear in output. Got: {}",
output.source,
);
assert!(
output.source.contains("rgb(214, 85, 50)"),
"Icon color should tint the icon glyph. Got: {}",
output.source,
);
assert!(
output.source.contains("#place(left + horizon, text("),
"Icon must be placed out of layout at the cell's left edge. Got: {}",
output.source,
);
assert!(
!output.source.contains(")[↑] 90"),
"Icon must not be an in-flow prefix of the value. Got: {}",
output.source,
);
}
#[test]
fn test_table_colspan_clamped_to_available_columns() {
let wide_cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Wide".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
col_span: 3,
..TableCell::default()
};
let table = Table {
rows: vec![
TableRow {
cells: vec![wide_cell],
height: None,
},
TableRow {
cells: vec![make_text_cell("A2"), make_text_cell("B2")],
height: None,
},
],
column_widths: vec![100.0, 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("colspan: 2"),
"Expected colspan clamped to 2, got: {result}"
);
assert!(
!result.contains("colspan: 3"),
"colspan: 3 should have been clamped, got: {result}"
);
}
#[test]
fn test_table_colspan_clamped_mid_row() {
let normal_cell = make_text_cell("A1");
let wide_cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Wide".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
col_span: 3,
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![normal_cell, wide_cell],
height: None,
}],
column_widths: vec![100.0, 100.0, 100.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("colspan: 2"),
"Expected colspan clamped to 2, got: {result}"
);
}
#[test]
fn test_table_colspan_no_column_widths_inferred() {
let wide_cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Wide".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
col_span: 5,
..TableCell::default()
};
let table = Table {
rows: vec![
TableRow {
cells: vec![wide_cell],
height: None,
},
TableRow {
cells: vec![
make_text_cell("A"),
make_text_cell("B"),
make_text_cell("C"),
],
height: None,
},
],
column_widths: vec![],
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("colspan: 3"),
"Expected colspan clamped to 3 (inferred columns), got: {result}"
);
assert!(
!result.contains("colspan: 5"),
"colspan: 5 should have been clamped, got: {result}"
);
}
#[test]
fn test_generate_typst_with_metadata_title_and_author() {
let doc = Document {
metadata: Metadata {
title: Some("Test Title".to_string()),
author: Some("Test Author".to_string()),
..Default::default()
},
pages: vec![make_flow_page(vec![Block::Paragraph(Paragraph {
runs: vec![Run {
text: "Hello".to_string(),
style: TextStyle::default(),
footnote: None,
href: None,
}],
style: ParagraphStyle::default(),
})])],
styles: StyleSheet::default(),
};
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#set document(title: \"Test Title\", author: \"Test Author\")"),
"Expected document metadata in Typst output, got: {result}"
);
}
#[test]
fn test_generate_typst_with_metadata_title_only() {
let doc = Document {
metadata: Metadata {
title: Some("Only Title".to_string()),
..Default::default()
},
pages: vec![make_flow_page(vec![Block::Paragraph(Paragraph {
runs: vec![Run {
text: "Hello".to_string(),
style: TextStyle::default(),
footnote: None,
href: None,
}],
style: ParagraphStyle::default(),
})])],
styles: StyleSheet::default(),
};
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#set document(title: \"Only Title\")"),
"Expected title-only metadata in Typst output, got: {result}"
);
}
#[test]
fn test_generate_typst_without_metadata() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
runs: vec![Run {
text: "Hello".to_string(),
style: TextStyle::default(),
footnote: None,
href: None,
}],
style: ParagraphStyle::default(),
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
!result.contains("#set document("),
"Should not emit #set document when no metadata, got: {result}"
);
}
#[test]
fn test_generate_typst_with_metadata_created_date() {
let doc = Document {
metadata: Metadata {
title: Some("Dated Doc".to_string()),
created: Some("2024-06-15T10:30:00Z".to_string()),
..Default::default()
},
pages: vec![make_flow_page(vec![Block::Paragraph(Paragraph {
runs: vec![Run {
text: "Hello".to_string(),
style: TextStyle::default(),
footnote: None,
href: None,
}],
style: ParagraphStyle::default(),
})])],
styles: StyleSheet::default(),
};
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("date: datetime(year: 2024, month: 6, day: 15"),
"Expected document date from metadata created field, got: {result}"
);
}
#[test]
fn test_generate_typst_with_metadata_date_only() {
let doc = Document {
metadata: Metadata {
created: Some("2023-12-25T08:00:00Z".to_string()),
..Default::default()
},
pages: vec![make_flow_page(vec![Block::Paragraph(Paragraph {
runs: vec![Run {
text: "Hello".to_string(),
style: TextStyle::default(),
footnote: None,
href: None,
}],
style: ParagraphStyle::default(),
})])],
styles: StyleSheet::default(),
};
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("date: datetime(year: 2023, month: 12, day: 25"),
"Expected document date even without title/author, got: {result}"
);
}
#[test]
fn test_generate_typst_with_invalid_created_date() {
let doc = Document {
metadata: Metadata {
title: Some("Bad Date Doc".to_string()),
created: Some("not-a-date".to_string()),
..Default::default()
},
pages: vec![make_flow_page(vec![Block::Paragraph(Paragraph {
runs: vec![Run {
text: "Hello".to_string(),
style: TextStyle::default(),
footnote: None,
href: None,
}],
style: ParagraphStyle::default(),
})])],
styles: StyleSheet::default(),
};
let result = generate_typst(&doc).unwrap().source;
assert!(
!result.contains("date: datetime("),
"Invalid date should not produce document date, got: {result}"
);
}
#[test]
fn test_parse_iso8601_date_full() {
let result = parse_iso8601_date("2024-06-15T10:30:45Z");
assert_eq!(result, Some((2024, 6, 15, 10, 30, 45)));
}
#[test]
fn test_parse_iso8601_date_date_only() {
let result = parse_iso8601_date("2023-12-25");
assert_eq!(result, Some((2023, 12, 25, 0, 0, 0)));
}
#[test]
fn test_parse_iso8601_date_invalid() {
assert_eq!(parse_iso8601_date("not-a-date"), None);
assert_eq!(parse_iso8601_date(""), None);
assert_eq!(parse_iso8601_date("2024"), None);
assert_eq!(parse_iso8601_date("2024-13-01T00:00:00Z"), None);
assert_eq!(parse_iso8601_date("2024-00-01T00:00:00Z"), None);
}
#[test]
fn test_triangle_polygon_codegen() {
let doc = make_doc(vec![make_fixed_page(
960.0,
540.0,
vec![make_shape_element(
10.0,
20.0,
200.0,
150.0,
ShapeKind::Polygon {
vertices: vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
},
Some(Color::new(255, 0, 0)),
None,
)],
)]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("#polygon("),
"Expected #polygon in: {}",
output.source
);
assert!(
output.source.contains("100pt"),
"Expected 100pt vertex x in: {}",
output.source
);
assert!(
output.source.contains("fill: rgb(255, 0, 0)"),
"Expected fill in: {}",
output.source
);
}
#[test]
fn test_rounded_rectangle_codegen() {
let doc = make_doc(vec![make_fixed_page(
960.0,
540.0,
vec![make_shape_element(
10.0,
20.0,
200.0,
100.0,
ShapeKind::RoundedRectangle {
radius_fraction: 0.1,
},
Some(Color::new(0, 0, 255)),
None,
)],
)]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("#rect("),
"Expected #rect in: {}",
output.source
);
assert!(
output.source.contains("radius:"),
"Expected radius parameter in: {}",
output.source
);
assert!(
output.source.contains("radius: 10pt"),
"Expected radius: 10pt in: {}",
output.source
);
}
#[test]
fn test_arrow_polygon_codegen() {
let doc = make_doc(vec![make_fixed_page(
960.0,
540.0,
vec![make_shape_element(
0.0,
0.0,
300.0,
150.0,
ShapeKind::Polygon {
vertices: vec![
(0.0, 0.25),
(0.6, 0.25),
(0.6, 0.0),
(1.0, 0.5),
(0.6, 1.0),
(0.6, 0.75),
(0.0, 0.75),
],
},
Some(Color::new(255, 136, 0)),
None,
)],
)]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("#polygon("),
"Expected #polygon for arrow in: {}",
output.source
);
assert!(
output.source.contains("300pt"),
"Expected 300pt (arrow tip) in: {}",
output.source
);
}
#[test]
fn test_polygon_with_stroke_codegen() {
let doc = make_doc(vec![make_fixed_page(
960.0,
540.0,
vec![make_shape_element(
0.0,
0.0,
100.0,
100.0,
ShapeKind::Polygon {
vertices: vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
},
None,
Some(BorderSide {
width: 2.0,
color: Color::new(0, 0, 0),
style: BorderLineStyle::Solid,
}),
)],
)]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("#polygon("),
"Expected #polygon in: {}",
output.source
);
assert!(
output.source.contains("stroke: 2pt + rgb(0, 0, 0)"),
"Expected stroke in: {}",
output.source
);
}
#[test]
fn test_font_substitution_calibri_produces_fallback_list() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Calibri text".to_string(),
style: TextStyle {
font_family: Some("Calibri".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(r#"font: ("Calibri", "Carlito", "Liberation Sans")"#),
"Expected font fallback list for Calibri in: {result}"
);
}
#[test]
fn test_font_substitution_arial_produces_fallback_list() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Arial text".to_string(),
style: TextStyle {
font_family: Some("Arial".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(r#"font: ("Arial", "Liberation Sans", "Arimo")"#),
"Expected font fallback list for Arial in: {result}"
);
}
#[test]
fn test_font_substitution_unknown_font_no_fallback() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Custom text".to_string(),
style: TextStyle {
font_family: Some("Helvetica".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(r#"font: "Helvetica""#),
"Unknown font should use simple quoted string in: {result}"
);
assert!(
!result.contains("font: (\"Helvetica\""),
"Unknown font should not use array syntax in: {result}"
);
}
#[test]
fn test_font_substitution_times_new_roman() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "TNR text".to_string(),
style: TextStyle {
font_family: Some("Times New Roman".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(r#"font: ("Times New Roman", "Liberation Serif", "Tinos")"#),
"Expected font fallback list for Times New Roman in: {result}"
);
}
#[test]
fn test_font_family_infers_medium_weight_from_family_name() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Title".to_string(),
style: TextStyle {
font_family: Some("Pretendard Medium".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(r#"weight: "medium""#),
"Expected medium weight inferred from family name in: {result}"
);
}
#[test]
fn test_font_family_infers_extrabold_weight_from_family_name() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Heading".to_string(),
style: TextStyle {
font_family: Some("Pretendard ExtraBold".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(r#"weight: "extrabold""#),
"Expected extrabold weight inferred from family name in: {result}"
);
}
#[test]
fn test_generate_typst_prefers_office_font_order_when_context_present() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Title".to_string(),
style: TextStyle {
font_family: Some("Pretendard".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let context = FontSearchContext::for_test(
Vec::new(),
&["Apple SD Gothic Neo", "Malgun Gothic"],
&["Malgun Gothic"],
&[],
);
let output = generate_typst_with_options_and_font_context(
&doc,
&ConvertOptions::default(),
Some(&context),
)
.unwrap();
let apple_index = output
.source
.find("\"Apple SD Gothic Neo\"")
.expect("Apple SD Gothic Neo should appear in Typst output");
let malgun_index = output
.source
.find("\"Malgun Gothic\"")
.expect("Malgun Gothic should appear in Typst output");
assert!(
malgun_index < apple_index,
"Office-resolved font ordering should win in Typst output: {}",
output.source
);
}
#[test]
fn test_generate_heading_level_1() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
heading_level: Some(1),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Main Title".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#heading(level: 1)[Main Title]"),
"H1 paragraph should emit #heading(level: 1): {result}"
);
}
#[test]
fn test_generate_heading_level_2() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
heading_level: Some(2),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Sub Section".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#heading(level: 2)[Sub Section]"),
"H2 paragraph should emit #heading(level: 2): {result}"
);
}
#[test]
fn test_generate_heading_levels_3_to_6() {
for level in 3..=6u8 {
let text = format!("Heading {level}");
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
heading_level: Some(level),
..ParagraphStyle::default()
},
runs: vec![Run {
text: text.clone(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
let expected = format!("#heading(level: {level})[{text}]");
assert!(
result.contains(&expected),
"H{level} should emit {expected}: {result}"
);
}
}
#[test]
fn test_generate_heading_with_styled_run() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
heading_level: Some(1),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Styled Heading".to_string(),
style: TextStyle {
bold: Some(true),
font_size: Some(24.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#heading(level: 1)"),
"Heading with styling should still emit #heading: {result}"
);
}
#[test]
fn test_generate_regular_paragraph_no_heading() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Normal text".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
!result.contains("#heading"),
"Regular paragraph should not emit #heading: {result}"
);
}
#[test]
fn test_spill_width_codegen() {
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "spilling text".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
spill_width: Some(200.0),
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: None,
}],
column_widths: vec![60.0],
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("width: 200pt"),
"spilled cell must lay text out across the spill width. Got: {}",
output.source,
);
assert!(
output.source.contains("clip: true"),
"spilled text must clip instead of wrapping. Got: {}",
output.source,
);
}
#[test]
fn test_table_default_vertical_align_codegen() {
let table = Table {
rows: vec![TableRow {
cells: vec![TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "value".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
..TableCell::default()
}],
height: None,
}],
column_widths: vec![100.0],
default_vertical_align: Some(CellVerticalAlign::Bottom),
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("align: bottom"),
"table-wide bottom alignment must be emitted. Got: {}",
output.source,
);
}
#[test]
fn test_vert_text_box_remaps_insets() {
let text_box = TextBoxData {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "세로".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
padding: Insets {
left: 7.2,
right: 7.2,
top: 3.6,
bottom: 3.6,
},
vertical_align: TextBoxVerticalAlign::Top,
fill: None,
opacity: None,
stroke: None,
shape_kind: None,
no_wrap: false,
auto_fit: false,
text_rotation_deg: Some(270.0),
};
let elem = FixedElement {
x: 0.0,
y: 0.0,
width: 100.0,
height: 50.0,
kind: FixedElementKind::TextBox(text_box),
};
let page = Page::Fixed(FixedPage {
size: PageSize::default(),
elements: vec![elem],
background_color: None,
background_gradient: None,
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
output
.source
.contains("inset: (top: 7.2pt, right: 3.6pt, bottom: 7.2pt, left: 3.6pt)"),
"270° rotation must remap the bodyPr insets. Got: {}",
output.source,
);
}
#[test]
fn test_non_repeating_header_rows_emit_a_separate_header_block() {
let row = |text: &str| TableRow {
cells: vec![TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: text.to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
..TableCell::default()
}],
height: Some(14.0),
};
let table = Table {
rows: vec![row("Title"), row("Spacer"), row("SKU"), row("SKU-1000")],
column_widths: vec![100.0],
header_row_count: 1,
non_repeating_header_row_count: 2,
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
let lead = output
.source
.find("table.header(repeat: false,")
.expect("non-repeating header emitted");
let repeating = output
.source
.find("table.header(level: 2,")
.expect("repeating header emitted at a higher level");
assert!(lead < repeating, "the non-repeating block comes first");
let title = output.source.find("Title").expect("title row present");
let sku = output.source.find("1000").expect("data row present");
assert!(lead < title && title < repeating && repeating < sku);
}
#[test]
fn test_header_without_non_repeating_rows_stays_a_single_block() {
let row = |text: &str| TableRow {
cells: vec![TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: text.to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
..TableCell::default()
}],
height: Some(14.0),
};
let table = Table {
rows: vec![row("SKU"), row("SKU-1000")],
column_widths: vec![100.0],
header_row_count: 1,
non_repeating_header_row_count: 0,
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(output.source.contains("table.header(\n"));
assert!(!output.source.contains("repeat: false"));
assert!(!output.source.contains("level: 2"));
}
#[test]
fn test_data_bar_is_inset_from_the_row_edges() {
use crate::ir::DataBarInfo;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "120".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
data_bar: Some(DataBarInfo {
color: Color::new(0x63, 0x8E, 0xC6),
fill_pct: 24.0,
}),
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: Some(14.0),
}],
column_widths: vec![100.0],
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("height: 10pt"),
"a 14pt row must print a 10pt bar. Got: {}",
output.source,
);
}
#[test]
fn test_data_bar_in_a_short_row_keeps_a_visible_height() {
use crate::ir::DataBarInfo;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "3".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
data_bar: Some(DataBarInfo {
color: Color::new(0x63, 0x8E, 0xC6),
fill_pct: 50.0,
}),
..TableCell::default()
};
let table = Table {
rows: vec![TableRow {
cells: vec![cell],
height: Some(3.0),
}],
column_widths: vec![100.0],
..Table::default()
};
let page = Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
});
let doc = make_doc(vec![page]);
let output = generate_typst(&doc).unwrap();
assert!(
!output.source.contains("height: 0pt"),
"the bar must stay visible. Got: {}",
output.source,
);
assert!(!output.source.contains("height: -"));
}
fn icon_cell(icon: &str, color: Color) -> TableCell {
TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "107%".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
icon_text: Some(icon.to_string()),
icon_color: Some(color),
..TableCell::default()
}
}
fn icon_sheet(cell: TableCell) -> Page {
Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table: Table {
rows: vec![TableRow {
cells: vec![cell],
height: Some(14.0),
}],
column_widths: vec![100.0],
..Table::default()
},
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
})
}
#[test]
fn test_arrow_icon_set_renders_as_a_filled_polygon() {
let doc = make_doc(vec![icon_sheet(icon_cell(
crate::ir::ICON_ARROW_UP,
Color::new(0x68, 0xA4, 0x90),
))]);
let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("polygon(fill: rgb(104, 164, 144)"),
"the arrow must be a polygon in the band color. Got: {}",
output.source,
);
assert!(
output.source.contains("darken(30%)"),
"Excel outlines the arrow a shade darker"
);
assert!(
!output.source.contains(crate::ir::ICON_ARROW_UP),
"the triangle character must not also be drawn"
);
}
#[test]
fn test_down_arrow_icon_is_flipped() {
let up = generate_typst(&make_doc(vec![icon_sheet(icon_cell(
crate::ir::ICON_ARROW_UP,
Color::new(0x68, 0xA4, 0x90),
))]))
.unwrap();
let down = generate_typst(&make_doc(vec![icon_sheet(icon_cell(
crate::ir::ICON_ARROW_DOWN,
Color::new(0xD6, 0x55, 0x32),
))]))
.unwrap();
assert!(up.source.contains("(4pt, 0pt)"), "up arrow tip at the top");
assert!(
down.source.contains("(4pt, 10pt)"),
"down arrow tip at the bottom. Got: {}",
down.source
);
}
#[test]
fn test_non_arrow_icon_set_still_renders_as_text() {
let doc = make_doc(vec![icon_sheet(icon_cell(
"●",
Color::new(0xD6, 0x55, 0x32),
))]);
let output = generate_typst(&doc).unwrap();
assert!(output.source.contains("text(fill: rgb(214, 85, 50)"));
assert!(!output.source.contains("polygon("));
}
fn make_sheet_text_box(anchor_row: u32, x_offset_pt: f64, height: f64) -> crate::ir::SheetTextBox {
crate::ir::SheetTextBox {
anchor_row,
x_offset_pt,
y_offset_pt: f64::from(anchor_row - 1) * 20.0,
width: 100.0,
height,
paragraphs: vec![Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: format!("shape at {x_offset_pt}"),
style: TextStyle::default(),
href: None,
footnote: None,
}],
}],
fill: None,
border: None,
vertical_center: false,
}
}
fn sheet_page_with_text_boxes(text_boxes: Vec<crate::ir::SheetTextBox>) -> Page {
Page::Sheet(SheetPage {
name: "Sheet1".to_string(),
size: PageSize::default(),
margins: Margins::default(),
table: Table {
rows: vec![TableRow {
cells: vec![TableCell::default()],
height: None,
}],
column_widths: vec![100.0],
..Table::default()
},
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes,
})
}
#[test]
fn test_sheet_drawings_overlay_the_grid_at_absolute_offsets() {
let doc = make_doc(vec![sheet_page_with_text_boxes(vec![
make_sheet_text_box(3, 0.0, 60.0),
make_sheet_text_box(3, 200.0, 60.0),
make_sheet_text_box(3, 400.0, 60.0),
])]);
let source = generate_typst(&doc).unwrap().source;
assert_eq!(
source.matches("#box(width: 100%, height: 0pt)").count(),
1,
"drawings share one zero-height overlay: {source}"
);
assert!(
!source.contains("#box(width: 100%, height: 60pt)"),
"no drawing may reserve flow height: {source}"
);
assert_eq!(
source.matches("dy: 40pt").count(),
3,
"same-row drawings share one vertical origin: {source}"
);
for dx in ["dx: 0pt", "dx: 200pt", "dx: 400pt"] {
assert!(
source.contains(dx),
"each drawing keeps its own horizontal offset ({dx}): {source}"
);
}
}