use super::*;
use crate::ir::{
ChartSeries, ColumnLayout, GradientStop, HeaderFooterParagraph, ImageData, ListItem, ListKind,
ListLevelStyle, Metadata, SmartArtNode, StyleSheet,
};
use std::collections::BTreeMap;
fn make_doc(pages: Vec<Page>) -> Document {
Document {
metadata: Metadata::default(),
pages,
styles: StyleSheet::default(),
}
}
fn make_flow_page(content: Vec<Block>) -> Page {
Page::Flow(FlowPage {
size: PageSize::default(),
margins: Margins::default(),
content,
header: None,
footer: None,
columns: None,
line_grid_pitch: None,
line_grid_snaps_lines: false,
})
}
fn make_paragraph(text: &str) -> Block {
Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: text.to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})
}
fn emitted_line_box_em(source: &str) -> Option<(f64, f64)> {
let after_top: &str = source.split_once("top-edge: ")?.1;
let (top, rest) = after_top.split_once("em")?;
let after_bottom: &str = rest.split_once("bottom-edge: -")?.1;
let (bottom, _) = after_bottom.split_once("em")?;
Some((top.parse().ok()?, bottom.parse().ok()?))
}
fn assert_line_advance(
source: &str,
family: &str,
font_size: f64,
expected_pt: f64,
east_asian_excess_em: f64,
) {
let (top, bottom) =
emitted_line_box_em(source).unwrap_or_else(|| panic!("no line box emitted in: {source}"));
let advance_pt: f64 = (top + bottom) * font_size;
assert!(
(advance_pt - expected_pt).abs() < 0.01,
"line advance {advance_pt}pt should be {expected_pt}pt in: {source}"
);
let (ascender, _descender, _) =
crate::render::pdf::font_line_metrics_em(family).expect("font metrics should resolve");
let expected_top: f64 = ascender + east_asian_excess_em;
assert!(
(top - expected_top).abs() < 0.001,
"baseline should sit {expected_top}em below the box top, not {top}em: {source}"
);
}
#[path = "typst_gen_paragraph_tests.rs"]
mod paragraph_tests;
#[path = "typst_gen_table_codegen_tests.rs"]
mod table_codegen_tests;
use self::table_codegen_tests::make_text_cell;
#[path = "typst_gen_image_tests.rs"]
mod image_tests;
fn make_fixed_page(width: f64, height: f64, elements: Vec<FixedElement>) -> Page {
Page::Fixed(FixedPage {
size: PageSize { width, height },
elements,
background_color: None,
background_gradient: None,
})
}
fn make_text_box(x: f64, y: f64, w: f64, h: f64, text: &str) -> FixedElement {
FixedElement {
x,
y,
width: w,
height: h,
kind: FixedElementKind::TextBox(crate::ir::TextBoxData {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: text.to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
padding: Insets::default(),
vertical_align: crate::ir::TextBoxVerticalAlign::Top,
fill: None,
opacity: None,
stroke: None,
shape_kind: None,
no_wrap: false,
auto_fit: false,
text_rotation_deg: None,
}),
}
}
fn make_shape_element(
x: f64,
y: f64,
w: f64,
h: f64,
kind: ShapeKind,
fill: Option<Color>,
stroke: Option<BorderSide>,
) -> FixedElement {
FixedElement {
x,
y,
width: w,
height: h,
kind: FixedElementKind::Shape(Shape {
kind,
fill,
gradient_fill: None,
stroke,
rotation_deg: None,
opacity: None,
shadow: None,
}),
}
}
fn make_fixed_text_box(
x: f64,
y: f64,
w: f64,
h: f64,
padding: Insets,
vertical_align: crate::ir::TextBoxVerticalAlign,
content: Vec<Block>,
) -> FixedElement {
FixedElement {
x,
y,
width: w,
height: h,
kind: FixedElementKind::TextBox(crate::ir::TextBoxData {
content,
padding,
vertical_align,
fill: None,
opacity: None,
stroke: None,
shape_kind: None,
no_wrap: false,
auto_fit: false,
text_rotation_deg: None,
}),
}
}
fn make_fixed_image(x: f64, y: f64, w: f64, h: f64, format: ImageFormat) -> FixedElement {
FixedElement {
x,
y,
width: w,
height: h,
kind: FixedElementKind::Image(ImageData {
data: vec![0x89, 0x50, 0x4E, 0x47], format,
width: Some(w),
height: Some(h),
crop: None,
stroke: None,
alignment: None,
clip_shape: None,
shadow: None,
paragraph_spacing: None,
}),
}
}
#[path = "typst_gen_fixed_page_tests.rs"]
mod fixed_page_tests;
#[path = "typst_gen_fixed_page_textbox_tests.rs"]
mod fixed_page_textbox_tests;
fn make_sheet_page(name: &str, width: f64, height: f64, margins: Margins, table: Table) -> Page {
Page::Sheet(crate::ir::SheetPage {
name: name.to_string(),
size: PageSize { width, height },
margins,
table,
header: None,
footer: None,
charts: vec![],
images: Vec::new(),
text_boxes: Vec::new(),
})
}
fn make_simple_table(rows: Vec<Vec<&str>>) -> Table {
Table {
rows: rows
.into_iter()
.map(|cells| TableRow {
cells: cells
.into_iter()
.map(|text| 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()
})
.collect(),
height: None,
})
.collect(),
column_widths: vec![],
..Table::default()
}
}
#[path = "typst_gen_table_page_tests.rs"]
mod table_page_tests;
#[path = "typst_gen_list_tests.rs"]
mod list_tests;
#[path = "typst_gen_page_misc_tests.rs"]
mod page_misc_tests;
#[path = "typst_gen_visual_tests.rs"]
mod visual_tests;
#[path = "typst_gen_diagram_visual_tests.rs"]
mod diagram_visual_tests;
#[path = "typst_gen_advanced_tests.rs"]
mod advanced_tests;
#[path = "typst_gen_text_pipeline_tests.rs"]
mod text_pipeline_tests;
#[test]
fn test_generate_run_superscript() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "2".to_string(),
style: TextStyle {
vertical_align: Some(VerticalTextAlign::Superscript),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#super[2]"),
"Superscript should use #super[...]. Got: {result}"
);
}
#[test]
fn test_generate_run_subscript() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "2".to_string(),
style: TextStyle {
vertical_align: Some(VerticalTextAlign::Subscript),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#sub[2]"),
"Subscript should use #sub[...]. Got: {result}"
);
}
#[test]
fn test_generate_run_small_caps() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Hello".to_string(),
style: TextStyle {
small_caps: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#smallcaps[Hello]"),
"Small caps should use #smallcaps[...]. Got: {result}"
);
}
#[test]
fn test_generate_run_all_caps() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Hello World".to_string(),
style: TextStyle {
all_caps: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("HELLO WORLD"),
"All caps should uppercase the text. Got: {result}"
);
}
#[test]
fn test_generate_run_superscript_with_bold() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "n".to_string(),
style: TextStyle {
vertical_align: Some(VerticalTextAlign::Superscript),
bold: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#super[") && result.contains("weight: \"bold\""),
"Superscript with bold should combine both. Got: {result}"
);
}
#[test]
fn test_generate_run_highlight_yellow() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Important".to_string(),
style: TextStyle {
highlight: Some(Color::new(255, 255, 0)),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#highlight(fill: rgb(255, 255, 0))[Important]"),
"Highlight should use #highlight(fill: ...). Got: {result}"
);
}
#[test]
fn test_table_cell_vertical_align_center() {
let table = Table {
rows: vec![TableRow {
cells: vec![TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Centered".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
vertical_align: Some(CellVerticalAlign::Center),
..TableCell::default()
}],
height: None,
}],
column_widths: vec![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("align: horizon"),
"Center vertical alignment should emit 'align: horizon'. Got: {result}"
);
}
#[test]
fn test_generate_run_highlight_with_bold() {
let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Bold Highlight".to_string(),
style: TextStyle {
highlight: Some(Color::new(0, 255, 0)),
bold: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains("#highlight(fill: rgb(0, 255, 0))["),
"Should have highlight wrapper. Got: {result}"
);
assert!(
result.contains("weight: \"bold\""),
"Should have bold text. Got: {result}"
);
}
#[test]
fn test_table_cell_vertical_align_bottom() {
let table = Table {
rows: vec![TableRow {
cells: vec![TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
runs: vec![Run {
text: "Bottom".to_string(),
style: TextStyle::default(),
href: None,
footnote: None,
}],
})],
vertical_align: Some(CellVerticalAlign::Bottom),
..TableCell::default()
}],
height: None,
}],
column_widths: vec![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("align: bottom"),
"Bottom vertical alignment should emit 'align: bottom'. Got: {result}"
);
}
#[test]
fn test_generate_blocks_empty_slice_produces_no_output() {
let blocks: Vec<Block> = vec![];
let mut out = String::new();
let mut ctx = GenCtx::new();
generate_blocks(&mut out, &blocks, &mut ctx).unwrap();
assert!(
out.is_empty(),
"Empty block slice should produce no output. Got: {out:?}"
);
}
#[test]
fn test_generate_blocks_single_block_no_leading_newline() {
let blocks: Vec<Block> = vec![make_paragraph("Hello")];
let mut out = String::new();
let mut ctx = GenCtx::new();
generate_blocks(&mut out, &blocks, &mut ctx).unwrap();
assert!(
!out.starts_with('\n'),
"Single block should not start with newline. Got: {out:?}"
);
assert!(
out.contains("Hello"),
"Output should contain block text. Got: {out:?}"
);
}
#[test]
fn test_generate_blocks_multiple_blocks_separated_by_newline() {
let blocks: Vec<Block> = vec![make_paragraph("First"), make_paragraph("Second")];
let mut out = String::new();
let mut ctx = GenCtx::new();
generate_blocks(&mut out, &blocks, &mut ctx).unwrap();
let first_pos: usize = out.find("First").expect("Should contain 'First'");
let second_pos: usize = out.find("Second").expect("Should contain 'Second'");
assert!(
first_pos < second_pos,
"First should appear before Second. Got: {out:?}"
);
let between: &str = &out[first_pos..second_pos];
assert!(
between.contains('\n'),
"Blocks should be separated by newline. Got between: {between:?}"
);
}
#[test]
fn test_generate_blocks_three_blocks_have_two_separators() {
let blocks: Vec<Block> = vec![
make_paragraph("A"),
make_paragraph("B"),
make_paragraph("C"),
];
let mut out = String::new();
let mut ctx = GenCtx::new();
generate_blocks(&mut out, &blocks, &mut ctx).unwrap();
assert!(out.contains("A"), "Should contain A. Got: {out:?}");
assert!(out.contains("B"), "Should contain B. Got: {out:?}");
assert!(out.contains("C"), "Should contain C. Got: {out:?}");
let pos_a: usize = out.find("A").expect("A");
let pos_b: usize = out.find("B").expect("B");
let pos_c: usize = out.find("C").expect("C");
assert!(pos_a < pos_b && pos_b < pos_c, "Order should be A < B < C");
}
#[test]
fn test_inferred_weight_not_emitted_when_font_unavailable() {
use crate::render::font_context::FontSearchContext;
let context = FontSearchContext::for_test(Vec::new(), &["Arial"], &[], &[]);
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 ExtraBold".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst_with_options_and_font_context(
&doc,
&ConvertOptions::default(),
Some(&context),
)
.unwrap()
.source;
assert!(
!result.contains("weight: \"extrabold\""),
"Should NOT emit extrabold weight when font is unavailable. Got: {result}"
);
}
#[test]
fn test_inferred_weight_emitted_when_font_available_via_alias() {
use crate::render::font_context::FontSearchContext;
let context = FontSearchContext::for_test(Vec::new(), &["Pretendard"], &[], &[]);
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 ExtraBold".to_string()),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst_with_options_and_font_context(
&doc,
&ConvertOptions::default(),
Some(&context),
)
.unwrap()
.source;
assert!(
result.contains("weight: \"extrabold\""),
"Should emit extrabold weight when font is available. Got: {result}"
);
}
#[test]
fn test_explicit_bold_still_emitted_when_font_unavailable() {
use crate::render::font_context::FontSearchContext;
let context = FontSearchContext::for_test(Vec::new(), &["Arial"], &[], &[]);
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 {
font_family: Some("Pretendard ExtraBold".to_string()),
bold: Some(true),
..TextStyle::default()
},
href: None,
footnote: None,
}],
})])]);
let result = generate_typst_with_options_and_font_context(
&doc,
&ConvertOptions::default(),
Some(&context),
)
.unwrap()
.source;
assert!(
result.contains("weight: \"bold\""),
"Explicit bold should still be emitted. Got: {result}"
);
assert!(
!result.contains("weight: \"extrabold\""),
"Should use bold, not extrabold (from unavailable font name). Got: {result}"
);
}
fn shadow_with(blur_radius: f64, opacity: f64) -> Shadow {
Shadow {
blur_radius,
distance: 3.0,
direction: 90.0,
color: Color { r: 0, g: 0, b: 0 },
opacity,
}
}
fn compound_coverage_at(layers: &[(f64, u8)], expansion: f64) -> f64 {
let mut keep = 1.0;
for (ring_expansion, alpha) in layers {
if *ring_expansion >= expansion - 1e-9 {
keep *= 1.0 - f64::from(*alpha) / 255.0;
}
}
1.0 - keep
}
#[test]
fn test_zero_blur_shadow_keeps_single_crisp_layer() {
let layers = shadow_blur_layers(&shadow_with(0.0, 0.4));
assert_eq!(layers, vec![(0.0, 102)]);
}
#[test]
fn test_blur_rings_span_two_sigma_each_side() {
let layers = shadow_blur_layers(&shadow_with(9.0, 0.4));
assert_eq!(layers.len(), 6);
let innermost = layers.iter().map(|(e, _)| *e).fold(f64::INFINITY, f64::min);
let outermost = layers
.iter()
.map(|(e, _)| *e)
.fold(f64::NEG_INFINITY, f64::max);
assert!((innermost - -5.4).abs() < 1e-9, "innermost {innermost}");
assert!((outermost - 5.4).abs() < 1e-9, "outermost {outermost}");
}
#[test]
fn test_blur_ring_coverage_follows_gaussian_cdf() {
let opacity = 0.4;
let layers = shadow_blur_layers(&shadow_with(9.0, opacity));
let core = compound_coverage_at(&layers, -5.4);
assert!((core - opacity).abs() < 0.02, "core coverage {core}");
let sigma = 2.7;
let at_edge = compound_coverage_at(&layers, 0.4 * sigma);
assert!(
(at_edge - opacity * 0.5).abs() < 0.03,
"edge-band coverage {at_edge}"
);
let rim = compound_coverage_at(&layers, 2.0 * sigma);
assert!(rim < opacity * 0.1, "rim coverage {rim}");
assert!(rim > 0.0, "rim must still be visible");
}
#[test]
fn test_blur_ring_coverage_is_monotonic_outward() {
let layers = shadow_blur_layers(&shadow_with(24.0, 0.6));
let mut expansions: Vec<f64> = layers.iter().map(|(e, _)| *e).collect();
expansions.sort_by(f64::total_cmp);
let coverages: Vec<f64> = expansions
.iter()
.map(|e| compound_coverage_at(&layers, *e))
.collect();
for pair in coverages.windows(2) {
assert!(
pair[0] > pair[1],
"coverage must fall outward: {coverages:?}"
);
}
let core = compound_coverage_at(&layers, -14.4);
assert!((core - 0.6).abs() < 0.02, "core coverage {core}");
assert!((expansions[5] - 14.4).abs() < 1e-9);
}