use super::*;
#[test]
fn test_generate_plain_paragraph() {
let doc = make_doc(vec![make_flow_page(vec![make_paragraph("Hello World")])]);
let result = generate_typst(&doc).unwrap().source;
assert!(result.contains("Hello World"));
}
#[test]
fn test_generate_empty_paragraph_reserves_line_height() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: Vec::new(),
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#v(12pt)"),
"empty DOCX paragraph marks should reserve vertical flow space: {result}"
);
}
#[test]
fn test_generate_page_setup() {
let doc = make_doc(vec![Page::Flow(FlowPage {
size: PageSize {
width: 612.0,
height: 792.0,
},
margins: Margins {
top: 36.0,
bottom: 36.0,
left: 54.0,
right: 54.0,
},
content: vec![make_paragraph("test")],
header: None,
footer: None,
columns: None,
line_grid_pitch: None,
})]);
let result = generate_typst(&doc).unwrap().source;
assert!(result.contains("612pt"));
assert!(result.contains("792pt"));
assert!(result.contains("36pt"));
assert!(result.contains("54pt"));
}
#[test]
fn test_generate_bold_text() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Bold text".to_string(),
style: TextStyle {
bold: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("weight: \"bold\""),
"Expected bold weight in: {result}"
);
assert!(result.contains("Bold text"));
}
#[test]
fn test_generate_italic_text() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Italic text".to_string(),
style: TextStyle {
italic: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("style: \"italic\""),
"Expected italic style in: {result}"
);
assert!(result.contains("Italic text"));
}
#[test]
fn test_generate_underline_text() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Underlined".to_string(),
style: TextStyle {
underline: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#underline["),
"Expected underline wrapper in: {result}"
);
assert!(result.contains("Underlined"));
}
#[test]
fn test_generate_font_size() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Large text".to_string(),
style: TextStyle {
font_size: Some(24.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("size: 24pt"),
"Expected font size in: {result}"
);
}
#[test]
fn test_generate_font_color() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Red text".to_string(),
style: TextStyle {
color: Some(Color::new(255, 0, 0)),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("fill: rgb(255, 0, 0)"),
"Expected RGB color in: {result}"
);
}
#[test]
fn test_generate_combined_text_styles() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Styled".to_string(),
style: TextStyle {
bold: Some(true),
italic: Some(true),
font_size: Some(16.0),
color: Some(Color::new(0, 128, 255)),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(result.contains("weight: \"bold\""));
assert!(result.contains("style: \"italic\""));
assert!(result.contains("size: 16pt"));
assert!(result.contains("fill: rgb(0, 128, 255)"));
assert!(result.contains("Styled"));
}
#[test]
fn test_generate_alignment_center() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
alignment: Some(Alignment::Center),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Centered".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("align(center"),
"Expected center alignment in: {result}"
);
}
#[test]
fn test_generate_alignment_right() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
alignment: Some(Alignment::Right),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Right".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("align(right"),
"Expected right alignment in: {result}"
);
}
#[test]
fn test_generate_alignment_justify() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
alignment: Some(Alignment::Justify),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Justified text".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("par(justify: true") || result.contains("set par(justify: true"),
"Expected justify in: {result}"
);
}
#[test]
fn test_generate_line_spacing_proportional() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
line_spacing: Some(LineSpacing::Proportional(2.0)),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Double spaced".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("leading:"),
"Expected leading setting in: {result}"
);
}
#[test]
fn test_generate_line_spacing_exact() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
line_spacing: Some(LineSpacing::Exact(18.0)),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Exact spaced".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("leading: 18pt"),
"Expected exact leading in: {result}"
);
}
#[test]
fn test_generate_word_default_line_box() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
line_box: Some(LineBox {
ascent_em: 1.3125,
descent_em: 0.4375,
}),
space_after: Some(8.0),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Word defaults".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let source = generate_typst(&doc).unwrap().source;
assert!(
source.contains("#set text(top-edge: 1.3125em, bottom-edge: -0.4375em)"),
"Expected Word-compatible line edges in: {source}"
);
assert!(
source.contains("#set par(leading: 0pt)"),
"Expected Word-compatible line stacking in: {source}"
);
assert!(
source.contains("below: 8pt"),
"Expected paragraph spacing in: {source}"
);
}
#[test]
fn test_generate_letter_spacing() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Spaced text".to_string(),
style: TextStyle {
letter_spacing: Some(2.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("tracking: 2pt"),
"Expected tracking param in: {result}"
);
}
#[test]
fn test_generate_letter_spacing_negative() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Condensed".to_string(),
style: TextStyle {
letter_spacing: Some(-0.5),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("tracking: -0.5pt"),
"Expected negative tracking in: {result}"
);
}
#[test]
fn test_generate_tab_uses_measured_default_stops() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Name:\tValue".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#context {"),
"Expected contextual tab rendering in: {result}"
);
assert!(
result.contains("measure(tab_prefix_0).width"),
"Expected tab spacing to measure the rendered prefix in: {result}"
);
assert!(
result.contains("calc.rem-euclid(tab_prefix_width_1.abs.pt(), 36)"),
"Expected default tabs to advance to the next 36pt stop in: {result}"
);
assert!(
!result.contains("#h(36pt)"),
"Expected default tabs to avoid a hard-coded 36pt gap in: {result}"
);
}
#[test]
fn test_generate_tab_uses_next_explicit_stop_and_alignment() {
use crate::ir::{TabAlignment, TabLeader, TabStop};
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
tab_stops: Some(vec![
TabStop {
position: 72.0,
alignment: TabAlignment::Left,
leader: TabLeader::None,
},
TabStop {
position: 216.0,
alignment: TabAlignment::Right,
leader: TabLeader::Dot,
},
]),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Col1\tCol2\tCol3".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("if tab_prefix_width_1 < 72pt"),
"Expected the first explicit stop to be chosen by measured width in: {result}"
);
assert!(
result.contains("else if tab_prefix_width_2 < 216pt"),
"Expected the next explicit stop to be selected after the first one in: {result}"
);
assert!(
result.contains("216pt - tab_prefix_width_2 - tab_segment_width_2"),
"Expected right-aligned tabs to subtract the following segment width in: {result}"
);
}
#[test]
fn test_generate_tab_falls_back_to_next_default_stop_after_explicit_tabs() {
use crate::ir::{TabAlignment, TabLeader, TabStop};
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
tab_stops: Some(vec![TabStop {
position: 100.0,
alignment: TabAlignment::Left,
leader: TabLeader::None,
}]),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "A\tB\tC".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("if tab_prefix_width_1 < 100pt"),
"Expected the explicit stop to be used when it is still ahead of the prefix in: {result}"
);
assert!(
result.contains("calc.rem-euclid(tab_prefix_width_2.abs.pt(), 36)"),
"Expected tabs beyond explicit stops to use the next default stop in: {result}"
);
}
#[test]
fn test_generate_tab_leader_uses_repeat_fill() {
use crate::ir::{TabAlignment, TabLeader, TabStop};
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
tab_stops: Some(vec![TabStop {
position: 144.0,
alignment: TabAlignment::Left,
leader: TabLeader::Dot,
}]),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Heading\t12".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("box(width: tab_advance_1, repeat[.])"),
"Expected dot tab leaders to render with Typst repeat fill in: {result}"
);
}
#[test]
fn test_generate_decimal_tab_uses_decimal_separator_not_thousands_separator() {
use crate::ir::{TabAlignment, TabLeader, TabStop};
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
tab_stops: Some(vec![TabStop {
position: 180.0,
alignment: TabAlignment::Decimal,
leader: TabLeader::None,
}]),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Total\t1,234.56".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("let tab_decimal_anchor_1 = [1,234]"),
"Expected decimal alignment to anchor after the thousands group in: {result}"
);
}
#[test]
fn test_generate_decimal_tab_handles_comma_decimal_locale() {
use crate::ir::{TabAlignment, TabLeader, TabStop};
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
tab_stops: Some(vec![TabStop {
position: 180.0,
alignment: TabAlignment::Decimal,
leader: TabLeader::None,
}]),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Total\t1.234,56".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("let tab_decimal_anchor_1 = [1.234]"),
"Expected decimal alignment to anchor on the locale decimal separator in: {result}"
);
}
#[test]
fn test_generate_multiple_paragraphs() {
let doc = make_doc(vec![make_flow_page(vec![
make_paragraph("First paragraph"),
make_paragraph("Second paragraph"),
])]);
let result = generate_typst(&doc).unwrap().source;
assert!(result.contains("First paragraph"));
assert!(result.contains("Second paragraph"));
assert!(
result.contains("First paragraph\n\nSecond paragraph"),
"Expected paragraph break between flow paragraphs in: {result}"
);
}
#[test]
fn test_generate_paragraph_with_multiple_runs() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![
Run {
text: "Normal ".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
},
Run {
text: "bold".to_string(),
style: TextStyle {
bold: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
},
Run {
text: " normal again".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
},
],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(result.contains("Normal "));
assert!(result.contains("bold"));
assert!(result.contains(" normal again"));
}
#[test]
fn test_generate_empty_document() {
let doc = make_doc(vec![]);
let result = generate_typst(&doc).unwrap().source;
assert!(result.is_empty() || !result.is_empty());
}
#[test]
fn test_generate_special_characters_escaped() {
let doc = make_doc(vec![make_flow_page(vec![make_paragraph(
"Price: $100 #items @store",
)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("\\#") || result.contains("Price"),
"Expected escaped or present text in: {result}"
);
}
#[test]
fn test_centered_paragraph_with_spacing_keeps_full_width_block() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
alignment: Some(Alignment::Center),
space_after: Some(6.0),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "Centered title".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("align(center"),
"Expected center alignment in: {result}"
);
let block_start = result.find("#block(").expect("expected block wrapper");
let block_params = &result[block_start..block_start + 60];
assert!(
block_params.contains("width: 100%"),
"Block wrapper must span the full width for alignment to apply: {block_params}"
);
}
#[test]
fn test_document_grid_pitch_snaps_line_height() {
let Some((ascender, descender, _)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return; };
let mut page = match make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "그리드 정렬 grid snapped".to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(10.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})]) {
Page::Flow(flow) => flow,
_ => unreachable!(),
};
page.line_grid_pitch = Some(18.0);
let doc = make_doc(vec![Page::Flow(page)]);
let result = generate_typst(&doc).unwrap().source;
let expected_leading = 18.0 - (ascender + descender) * 10.0;
assert!(
result.contains("top-edge: \"ascender\""),
"metric edges expected in: {result}"
);
assert!(
result.contains(&format!("leading: {}pt", format_f64(expected_leading))),
"grid leading {expected_leading} expected in: {result}"
);
}
#[test]
fn test_latin_paragraph_ignores_document_grid() {
let mut page = match make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "latin only body text".to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(10.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})]) {
Page::Flow(flow) => flow,
_ => unreachable!(),
};
page.line_grid_pitch = Some(18.0);
let doc = make_doc(vec![Page::Flow(page)]);
let result = generate_typst(&doc).unwrap().source;
let Some((ascender, descender, word_pitch)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return;
};
let box_pt = (ascender + descender) * 10.0;
let single_leading = (word_pitch * 10.0 - box_pt).max(0.0);
let grid_leading = 18.0 - box_pt;
assert!(
result.contains(&format!("leading: {}pt", format_f64(single_leading))),
"Latin paragraphs keep Word single spacing: {result}"
);
assert!(
!result.contains(&format!("leading: {}pt", format_f64(grid_leading))),
"Latin paragraphs must not snap to the grid: {result}"
);
}
#[test]
fn test_no_document_grid_uses_word_single_spacing() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "plain".to_string(),
style: TextStyle {
font_family: Some("Libertinus Serif".to_string()),
font_size: Some(10.0),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
let Some((ascender, descender, word_pitch)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return;
};
let single_leading = (word_pitch * 10.0 - (ascender + descender) * 10.0).max(0.0);
assert!(
result.contains("top-edge: \"ascender\""),
"metric edges expected: {result}"
);
assert!(
result.contains(&format!("leading: {}pt", format_f64(single_leading))),
"Word single-spacing leading expected: {result}"
);
}
#[test]
fn test_generate_paragraph_with_background_shading() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
background: Some(Color::new(0xF4, 0xF4, 0xF4)),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "$ cargo install office2pdf-cli".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("fill: rgb(244, 244, 244)"),
"paragraph shading must fill the block wrapper: {result}"
);
assert!(
result.contains("#block(width: 100%"),
"shaded paragraphs need the full-width block wrapper: {result}"
);
}
#[test]
fn test_generate_paragraph_with_bottom_border_rule() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
border: Some(Box::new(CellBorder {
bottom: Some(BorderSide {
width: 0.75,
color: Color::new(0x1E, 0x27, 0x61),
style: BorderLineStyle::Solid,
}),
..CellBorder::default()
})),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "JAMIE PARKER".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("stroke: (bottom: 0.75pt + rgb(30, 39, 97))"),
"bottom border must stroke the wrapper: {result}"
);
}
#[test]
fn test_generate_paragraph_with_double_bottom_border() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle {
border: Some(Box::new(CellBorder {
bottom: Some(BorderSide {
width: 1.0,
color: Color::black(),
style: BorderLineStyle::Double,
}),
..CellBorder::default()
})),
..ParagraphStyle::default()
},
runs: vec![Run {
text: "주식회사 에이엑스솔루션".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
let rule_count = result.matches("line(length: 100%").count();
assert_eq!(
rule_count, 2,
"double borders draw exactly two rules: {result}"
);
assert!(
!result.contains("stroke: (bottom:"),
"double sides must not also stroke the wrapper: {result}"
);
}