use base64::Engine as _;
use crate::{
elements::{
fixed_text::{FixedTextBox, VerticalAlign},
image::{ImageAlignment, ImageElement},
list::{BulletList, CheckList, CheckListItem, ListItemElement, OrderedList},
page_break::PageBreakElement,
paragraph::{Paragraph, ParagraphContent, TextRun},
section::Section,
spacer::HorizontalRuleElement,
table::{Table, TableCell, TableStyle},
Element,
},
layout::{BorderStyle, BoxBorder, FixedBox, OverflowPolicy, TextAlign},
richtext::{
marks::{AppliedStyle, MarkValue},
model::{
Block, FixedBoxBlock, HeadingBlock, ImageAlign, ImageBlock, Inline, ListType,
NcrtfDocument, ParagraphBlock, TableBlock,
},
},
styles::{DocumentStyle, RgbColor},
};
const LINK_COLOR: RgbColor = RgbColor { r: 0.0, g: 0.2, b: 0.6 };
const BLOCKQUOTE_COLOR: RgbColor = RgbColor { r: 0.47, g: 0.47, b: 0.47 };
pub fn ncrtf_to_elements(
doc: &NcrtfDocument,
_style: &DocumentStyle,
) -> Vec<Box<dyn Element>> {
let mut elements: Vec<Box<dyn Element>> = Vec::new();
for block in &doc.blocks {
match block {
Block::Heading(h) => elements.push(heading_to_section(h)),
Block::Paragraph(p) => elements.push(paragraph_block_to_element(p)),
Block::List(l) => match l.list_type {
ListType::Bullet => {
let items = l
.children
.iter()
.map(|li| ListItemElement {
indent: li.indent.unwrap_or(0),
runs: inlines_to_runs(&li.children),
})
.collect();
elements.push(Box::new(BulletList { items }));
}
ListType::Ordered => {
let items = l
.children
.iter()
.map(|li| ListItemElement {
indent: li.indent.unwrap_or(0),
runs: inlines_to_runs(&li.children),
})
.collect();
elements.push(Box::new(OrderedList { start: 1, items }));
}
ListType::Checklist => {
let items = l
.children
.iter()
.map(|li| CheckListItem {
checked: li.checked.unwrap_or(false),
indent: li.indent.unwrap_or(0),
runs: inlines_to_runs(&li.children),
})
.collect();
elements.push(Box::new(CheckList { items }));
}
},
Block::Table(t) => {
table_block_to_elements(t, &mut elements);
}
Block::Blockquote(bq) => {
let runs = inlines_to_runs_colored(&bq.children, &BLOCKQUOTE_COLOR);
let mut p = Paragraph::from_runs(runs, TextAlign::Left, None);
p.indent_left_mm = 8.0;
p.indent_right_mm = 8.0;
elements.push(Box::new(p));
if let Some(ref attr) = bq.attribution {
let run = TextRun {
text: format!("— {attr}"),
style: AppliedStyle {
italic: true,
color: Some("#787878".into()),
..Default::default()
},
..Default::default()
};
let mut attr_para = Paragraph::from_runs(vec![run], TextAlign::Right, None);
attr_para.indent_right_mm = 8.0;
elements.push(Box::new(attr_para));
}
}
Block::CodeBlock(cb) => {
let run = TextRun {
text: cb.code.clone(),
style: AppliedStyle { code: true, ..Default::default() },
..Default::default()
};
elements.push(Box::new(Paragraph::from_runs(vec![run], TextAlign::Left, None)));
}
Block::Image(img) => elements.push(image_block_to_element(img)),
Block::HorizontalRule => {
elements.push(Box::new(HorizontalRuleElement));
}
Block::PageBreak => elements.push(Box::new(PageBreakElement)),
Block::FixedBox(fb) => elements.push(fixed_box_to_element(fb)),
}
}
elements
}
fn heading_to_section(h: &HeadingBlock) -> Box<dyn Element> {
let text = inlines_to_text(&h.children);
let level = h.level.clamp(1, 3);
Box::new(Section::new(text, level))
}
fn paragraph_block_to_element(p: &ParagraphBlock) -> Box<dyn Element> {
let runs = inlines_to_runs(&p.children);
let alignment = convert_alignment(p.alignment.as_ref());
let mut para = Paragraph::from_runs(runs, alignment, None);
if let Some(ref name) = p.style {
para.style_ref = Some(name.clone());
}
if let Some(level) = p.indent {
para.indent_left_mm = level as f64 * 10.0;
}
Box::new(para)
}
fn table_block_to_elements(t: &TableBlock, out: &mut Vec<Box<dyn Element>>) {
if let Some(ref cap) = t.caption {
let run = TextRun {
text: cap.clone(),
style: AppliedStyle { italic: true, ..Default::default() },
..Default::default()
};
let mut cap_para = Paragraph::from_runs(vec![run], TextAlign::Left, None);
cap_para.space_after_mm = Some(1.0);
out.push(Box::new(cap_para));
}
let mut builder = Table::builder().table_style(TableStyle::grid());
for hrow in &t.head {
let cells: Vec<TableCell> = hrow
.cells
.iter()
.map(|c| ncrtf_cell_to_table_cell(c, true))
.collect();
builder = builder.header_row(cells);
}
for brow in &t.body {
let cells: Vec<TableCell> = brow
.cells
.iter()
.map(|c| ncrtf_cell_to_table_cell(c, false))
.collect();
builder = builder.row(cells);
}
if let Some(widths) = &t.col_widths {
builder = builder.col_widths(widths.clone());
}
out.push(Box::new(builder.build()));
}
fn ncrtf_cell_to_table_cell(
c: &crate::richtext::model::TableCell,
force_header: bool,
) -> TableCell {
let text = inlines_to_text(&c.children);
let is_header = c.header || force_header;
let alignment = convert_alignment(c.alignment.as_ref());
let col_span = c.col_span.unwrap_or(1).max(1) as u16;
let row_span = c.row_span.unwrap_or(1).max(1) as u16;
let mut cell = TableCell::new(text).align(alignment).col_span(col_span).row_span(row_span);
if is_header {
cell.style_ref = Some("table_header".into());
}
cell
}
fn image_block_to_element(img: &ImageBlock) -> Box<dyn Element> {
let data = decode_data_uri(&img.src);
let alignment = match img.alignment.as_ref().unwrap_or(&ImageAlign::Center) {
ImageAlign::Left => ImageAlignment::Left,
ImageAlign::Center => ImageAlignment::Center,
ImageAlign::Right => ImageAlignment::Right,
};
let mut element = ImageElement::new(data).align(alignment);
if let Some(cap) = &img.caption {
element = element.caption(cap.to_owned());
}
if let Some(alt) = &img.alt {
element = element.alt(alt.to_owned());
}
if let Some(pct) = img.width_percent {
element.width_percent = Some(pct);
}
Box::new(element)
}
pub fn inlines_to_text(inlines: &[Inline]) -> String {
inlines
.iter()
.map(|i| match i {
Inline::Text(t) => t.text.clone(),
Inline::Link(l) => inlines_to_text(&l.children),
Inline::HardBreak => "\n".to_string(),
Inline::FootnoteRef(r) => r.number.to_string(),
})
.collect()
}
pub fn inlines_to_runs(inlines: &[Inline]) -> Vec<TextRun> {
inlines_to_runs_colored(inlines, &RgbColor::new(0.0, 0.0, 0.0))
}
fn inlines_to_runs_colored(inlines: &[Inline], base_color: &RgbColor) -> Vec<TextRun> {
let is_black = base_color.r == 0.0 && base_color.g == 0.0 && base_color.b == 0.0;
let color_hex = if is_black {
None
} else {
Some(format!(
"#{:02X}{:02X}{:02X}",
(base_color.r * 255.0) as u8,
(base_color.g * 255.0) as u8,
(base_color.b * 255.0) as u8,
))
};
let mut runs = Vec::new();
for inline in inlines {
match inline {
Inline::Text(t) => {
let marks: &[MarkValue] = t.marks.as_deref().unwrap_or(&[]);
let mut style = AppliedStyle::from(marks);
if style.color.is_none() {
style.color = color_hex.clone();
}
runs.push(TextRun {
text: t.text.clone(),
style,
opentype: t.opentype_features.clone().unwrap_or_default(),
..Default::default()
});
}
Inline::Link(l) => {
for mut run in inlines_to_runs(&l.children) {
run.style.underline = true;
if run.style.color.is_none() {
run.style.color = Some(format!(
"#{:02X}{:02X}{:02X}",
(LINK_COLOR.r * 255.0) as u8,
(LINK_COLOR.g * 255.0) as u8,
(LINK_COLOR.b * 255.0) as u8,
));
}
runs.push(run);
}
}
Inline::HardBreak => {
runs.push(TextRun { text: "\n".into(), ..Default::default() });
}
Inline::FootnoteRef(r) => {
runs.push(TextRun::footnote_ref(r.number));
}
}
}
runs
}
fn convert_alignment(a: Option<&TextAlign>) -> TextAlign {
match a {
Some(align) => *align,
None => TextAlign::Left,
}
}
fn fixed_box_to_element(fb: &FixedBoxBlock) -> Box<dyn Element> {
let overflow = match fb.overflow.as_deref() {
Some("clip") => OverflowPolicy::Clip,
Some("shrink") => OverflowPolicy::Shrink,
Some("overflow") => OverflowPolicy::Overflow,
_ => OverflowPolicy::Truncate,
};
let border = fb.border.as_ref().map(|b| {
let style = match b.style.as_deref() {
Some("dashed") => BorderStyle::Dashed,
Some("dotted") => BorderStyle::Dotted,
_ => BorderStyle::Solid,
};
BoxBorder {
width_mm: b.width_mm,
color: RgbColor::from_hex(&b.color)
.unwrap_or(RgbColor::new(0.0, 0.0, 0.0)),
style,
}
});
let background = fb.background.as_deref().and_then(RgbColor::from_hex);
Box::new(FixedTextBox {
text_box: FixedBox {
x_mm: fb.x_mm,
y_mm: fb.y_mm,
width_mm: fb.width_mm,
height_mm: fb.height_mm,
overflow,
border,
background,
padding_mm: fb.padding_mm.unwrap_or(2.0),
z_index: 0,
ua_role: None,
ua_alt: None,
},
content: ParagraphContent::Runs(inlines_to_runs(&fb.children)),
alignment: convert_alignment(fb.alignment.as_ref()),
font_size: None,
vertical_align: VerticalAlign::Top,
})
}
fn decode_data_uri(src: &str) -> Vec<u8> {
if let Some(pos) = src.find(";base64,") {
let payload = &src[pos + 8..];
base64::engine::general_purpose::STANDARD
.decode(payload.trim())
.unwrap_or_default()
} else {
Vec::new()
}
}