use scraper::{Html, Selector};
use crate::get_font_for_locale;
#[derive(Debug, Clone)]
pub struct HtmlParagraph {
pub content: String,
pub locale: String,
pub max_font_size: Option<u32>,
pub font_family: Option<String>,
pub font_size: Option<u32>,
pub font_weight: Option<u16>,
pub color: Option<String>,
}
pub struct HtmlToParagraph {
pub default_font: String,
pub default_font_size: u32,
pub max_font_size: Option<u32>,
pub default_font_weight: u16,
pub default_color: Option<String>,
}
impl Default for HtmlToParagraph {
fn default() -> Self {
Self {
default_font: "Arial".to_owned(),
default_font_size: 22, max_font_size: None,
default_font_weight: 400,
default_color: None,
}
}
}
#[derive(Debug, Clone)]
struct TextStyle {
bold: bool,
italic: bool,
underline: bool,
strike: bool,
color: Option<String>,
background_color: Option<String>,
font_size: u32, font_family: String,
}
impl TextStyle {
fn new(font_family: String, font_size: u32) -> Self {
Self {
bold: false,
italic: false,
underline: false,
strike: false,
color: None,
background_color: None,
font_size,
font_family,
}
}
}
#[derive(Debug, Clone, Copy)]
enum TextAlign {
Left,
Center,
Right,
Justify,
}
impl TextAlign {
fn to_docx(&self) -> &'static str {
match self {
TextAlign::Left => "left",
TextAlign::Center => "center",
TextAlign::Right => "right",
TextAlign::Justify => "both",
}
}
}
impl Default for TextAlign {
fn default() -> Self {
TextAlign::Left
}
}
#[derive(Debug, Clone)]
struct TextRun {
text: String,
style: TextStyle,
}
#[derive(Debug)]
struct Paragraph {
runs: Vec<TextRun>,
align: TextAlign,
is_heading: bool,
heading_level: u8,
is_list_item: bool,
list_type: ListType,
list_index: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ListType {
None,
Ordered, Unordered, }
impl HtmlToParagraph {
fn initial_style(&self, font_size: u32) -> TextStyle {
let mut style = TextStyle::new(self.default_font.clone(), font_size);
style.bold = self.default_font_weight >= 700;
style.color = self.default_color.clone();
style
}
pub fn new() -> Self {
Self::default()
}
pub fn with_locale(mut self, locale: String) -> Self {
self.default_font = get_font_for_locale(&locale);
self
}
pub fn with_font(mut self, font: String) -> Self {
self.default_font = font;
self
}
pub fn with_font_size(mut self, size: u32) -> Self {
self.default_font_size = size;
self
}
pub fn with_font_weight(mut self, weight: u16) -> Self {
self.default_font_weight = weight;
self
}
pub fn with_color(mut self, color: String) -> Self {
self.default_color = Some(color.trim_start_matches('#').to_string());
self
}
pub fn with_max_font_size(mut self, size: u32) -> Self {
self.max_font_size = Some(size);
self
}
pub fn convert(&self, html: &str) -> String {
println!("{}", html);
let trimmed = html.trim();
let needs_wrapping = if trimmed.is_empty() {
false
} else if !trimmed.starts_with('<') {
true
} else {
let block_tags = ["<p", "<h1", "<h2", "<h3", "<h4", "<h5", "<h6", "<ul", "<ol"];
!block_tags.iter().any(|tag| {
trimmed.starts_with(tag)
&& (trimmed[tag.len()..].starts_with('>')
|| trimmed[tag.len()..].starts_with(' '))
})
};
let html_to_parse = if needs_wrapping {
format!("<p>{}</p>", html)
} else {
html.to_string()
};
let document = Html::parse_document(&html_to_parse);
let paragraphs = self.parse_html(&document);
self.generate_docx_xml(¶graphs)
}
fn parse_html(&self, document: &Html) -> Vec<Paragraph> {
let mut paragraphs = Vec::new();
let body_selector = Selector::parse("body").unwrap();
let root = if let Some(body) = document.select(&body_selector).next() {
body
} else {
let html_selector = Selector::parse("html").unwrap();
if let Some(html) = document.select(&html_selector).next() {
html
} else {
return paragraphs;
}
};
for child in root.children() {
if let Some(element) = scraper::ElementRef::wrap(child) {
match element.value().name() {
"p" => {
let paragraph = self.parse_paragraph(element, false, 0);
paragraphs.push(paragraph);
}
"h1" => {
let paragraph = self.parse_paragraph(element, true, 1);
paragraphs.push(paragraph);
}
"h2" => {
let paragraph = self.parse_paragraph(element, true, 2);
paragraphs.push(paragraph);
}
"h3" => {
let paragraph = self.parse_paragraph(element, true, 3);
paragraphs.push(paragraph);
}
"h4" => {
let paragraph = self.parse_paragraph(element, true, 4);
paragraphs.push(paragraph);
}
"h5" => {
let paragraph = self.parse_paragraph(element, true, 5);
paragraphs.push(paragraph);
}
"h6" => {
let paragraph = self.parse_paragraph(element, true, 6);
paragraphs.push(paragraph);
}
"ol" => {
let list_paragraphs = self.parse_list(element, ListType::Ordered);
paragraphs.extend(list_paragraphs);
}
"ul" => {
let list_paragraphs = self.parse_list(element, ListType::Unordered);
paragraphs.extend(list_paragraphs);
}
_ => {}
}
}
else if let scraper::Node::Text(text) = child.value() {
let text_content = text.to_string();
if !text_content.trim().is_empty() {
let initial_style = self.initial_style(self.default_font_size);
let runs = vec![TextRun {
text: text_content,
style: initial_style,
}];
paragraphs.push(Paragraph {
runs,
align: TextAlign::Left,
is_heading: false,
heading_level: 0,
is_list_item: false,
list_type: ListType::None,
list_index: None,
});
}
}
}
paragraphs
}
fn parse_list(&self, list_element: scraper::ElementRef, list_type: ListType) -> Vec<Paragraph> {
let mut paragraphs = Vec::new();
let li_selector = Selector::parse("li").unwrap();
let mut actual_index = 0;
for li_element in list_element.select(&li_selector) {
let mut runs = Vec::new();
let initial_style = self.initial_style(self.default_font_size);
self.parse_node_content(li_element, &mut runs, initial_style);
let has_content = runs.iter().any(|run| !run.text.trim().is_empty());
if has_content {
paragraphs.push(Paragraph {
runs,
align: TextAlign::Left,
is_heading: false,
heading_level: 0,
is_list_item: true,
list_type,
list_index: Some(actual_index),
});
actual_index += 1; }
}
paragraphs
}
fn parse_paragraph(
&self,
element: scraper::ElementRef,
is_heading: bool,
heading_level: u8,
) -> Paragraph {
let mut runs = Vec::new();
let mut align = TextAlign::Left;
if let Some(style) = element.value().attr("style") {
align = self.parse_text_align(style);
}
if let Some(class) = element.value().attr("class") {
if class.contains("text-center") {
align = TextAlign::Center;
} else if class.contains("text-right") {
align = TextAlign::Right;
} else if class.contains("text-justify") {
align = TextAlign::Justify;
}
}
let font_size = if is_heading {
match heading_level {
1 => self.default_font_size * 2, 2 => (self.default_font_size * 3) / 2, 3 => (self.default_font_size * 5) / 4, 4 => self.default_font_size, 5 => (self.default_font_size * 7) / 8, 6 => (self.default_font_size * 3) / 4, _ => self.default_font_size,
}
} else {
if let Some(class) = element.value().attr("class") {
self.parse_font_size_from_class(class)
} else {
self.default_font_size
}
};
let font_size = self.clamp_font_size(font_size);
let initial_style = self.initial_style(font_size);
self.parse_node_content(element, &mut runs, initial_style);
Paragraph {
runs,
align,
is_heading,
heading_level,
is_list_item: false,
list_type: ListType::None,
list_index: None,
}
}
fn parse_text_align(&self, style: &str) -> TextAlign {
if style.contains("text-align: center") || style.contains("text-align:center") {
TextAlign::Center
} else if style.contains("text-align: right") || style.contains("text-align:right") {
TextAlign::Right
} else if style.contains("text-align: justify") || style.contains("text-align:justify") {
TextAlign::Justify
} else {
TextAlign::Left
}
}
fn parse_font_size_from_class(&self, class: &str) -> u32 {
let size = if class.contains("text-xs") {
(self.default_font_size * 7) / 8 } else if class.contains("text-sm") {
self.default_font_size } else if class.contains("text-base") {
self.default_font_size } else if class.contains("text-lg") {
(self.default_font_size * 5) / 4 } else if class.contains("text-xl") {
(self.default_font_size * 3) / 2 } else if class.contains("text-2xl") {
self.default_font_size * 2 } else if class.contains("text-3xl") {
(self.default_font_size * 5) / 2 } else if class.contains("text-4xl") {
self.default_font_size * 3 } else {
self.default_font_size
};
self.clamp_font_size(size)
}
fn parse_node_content(
&self,
element: scraper::ElementRef,
runs: &mut Vec<TextRun>,
parent_style: TextStyle,
) {
for node in element.children() {
match node.value() {
scraper::Node::Text(text) => {
let text_content = text.to_string();
let normalized = Self::normalize_whitespace(&text_content);
if !normalized.is_empty() {
runs.push(TextRun {
text: normalized,
style: parent_style.clone(),
});
}
}
scraper::Node::Element(elem) => {
let child_element = scraper::ElementRef::wrap(node).unwrap();
let mut new_style = parent_style.clone();
match elem.name() {
"strong" | "b" => new_style.bold = true,
"em" | "i" => new_style.italic = true,
"u" => new_style.underline = true,
"s" | "strike" | "del" => new_style.strike = true,
"span" => {
if let Some(style) = elem.attr("style") {
self.parse_inline_style(style, &mut new_style);
}
}
"mark" => {
if let Some(style) = elem.attr("style") {
self.parse_inline_style(style, &mut new_style);
}
if new_style.background_color.is_none() {
if let Some(data_color) = elem.attr("data-color") {
new_style.background_color = Some(data_color.to_string());
} else {
new_style.background_color = Some("FFFF00".to_string());
}
}
}
"br" => {
runs.push(TextRun {
text: "\n".to_string(),
style: parent_style.clone(),
});
continue;
}
_ => {}
}
self.parse_node_content(child_element, runs, new_style);
}
_ => {}
}
}
}
fn parse_inline_style(&self, style: &str, text_style: &mut TextStyle) {
if let Some(bg_start) = style.find("background-color:") {
let bg_str = &style[bg_start + 17..];
if let Some(bg_end) = bg_str.find(';').or_else(|| Some(bg_str.len())) {
let bg_color = bg_str[..bg_end].trim();
text_style.background_color = Some(self.parse_color(bg_color));
}
}
for (i, _) in style.match_indices("color:") {
let is_background_color = if i >= 11 {
&style[i - 11..i] == "background-"
} else {
false
};
if !is_background_color {
let color_str = &style[i + 6..];
if let Some(color_end) = color_str.find(';').or_else(|| Some(color_str.len())) {
let color = color_str[..color_end].trim();
if color != "inherit" {
text_style.color = Some(self.parse_color(color));
}
}
break;
}
}
if let Some(size_start) = style.find("font-size:") {
let size_str = &style[size_start + 10..];
if let Some(size_end) = size_str.find(';').or_else(|| Some(size_str.len())) {
let size = size_str[..size_end].trim();
if let Some(parsed_size) = self.parse_font_size(size) {
text_style.font_size = self.clamp_font_size(parsed_size);
}
}
}
}
fn parse_color(&self, color: &str) -> String {
let color = color.trim();
if color.starts_with('#') {
let hex = &color[1..];
if hex.len() == 6 {
return hex.to_uppercase();
} else if hex.len() == 3 {
let r = &hex[0..1];
let g = &hex[1..2];
let b = &hex[2..3];
return format!("{}{}{}{}{}{}", r, r, g, g, b, b).to_uppercase();
}
}
if color.starts_with("rgb(") && color.ends_with(')') {
let rgb = &color[4..color.len() - 1];
let parts: Vec<&str> = rgb.split(',').collect();
if parts.len() == 3 {
let r = parts[0].trim().parse::<u8>().unwrap_or(0);
let g = parts[1].trim().parse::<u8>().unwrap_or(0);
let b = parts[2].trim().parse::<u8>().unwrap_or(0);
return format!("{:02X}{:02X}{:02X}", r, g, b);
}
}
"000000".to_string()
}
fn parse_font_size(&self, size: &str) -> Option<u32> {
let size = size.trim();
if size.ends_with("px") {
let px = size[..size.len() - 2].parse::<f64>().ok()?;
let pt = px / 1.333;
Some((pt * 2.0) as u32) } else if size.ends_with("pt") {
let pt = size[..size.len() - 2].parse::<f64>().ok()?;
Some((pt * 2.0) as u32)
} else {
None
}
}
fn clamp_font_size(&self, size: u32) -> u32 {
self.max_font_size.map(|max| size.min(max)).unwrap_or(size)
}
fn generate_docx_xml(&self, paragraphs: &[Paragraph]) -> String {
let mut xml = String::new();
for paragraph in paragraphs {
xml.push_str(&self.generate_paragraph_xml(paragraph));
}
xml
}
fn generate_paragraph_xml(&self, paragraph: &Paragraph) -> String {
let mut xml = String::new();
xml.push_str("<w:p>");
let has_list = paragraph.is_list_item && paragraph.list_type != ListType::None;
let has_align = paragraph.align as u8 != TextAlign::Left as u8;
let has_heading = paragraph.is_heading;
if has_list || has_align || has_heading {
xml.push_str("<w:pPr>");
if has_heading {
xml.push_str(&format!(
r#"<w:pStyle w:val="Heading{}"/>"#,
paragraph.heading_level
));
}
if has_list {
let num_id = match paragraph.list_type {
ListType::Ordered => 1,
ListType::Unordered => 2,
ListType::None => 0,
};
xml.push_str(&format!(
r#"<w:numPr><w:ilvl w:val="0"/><w:numId w:val="{}"/></w:numPr>"#,
num_id
));
}
if has_align {
xml.push_str(&format!(r#"<w:jc w:val="{}"/>"#, paragraph.align.to_docx()));
}
xml.push_str("</w:pPr>");
}
for run in ¶graph.runs {
xml.push_str(&self.generate_run_xml(run));
}
xml.push_str("</w:p>");
xml
}
fn generate_run_xml(&self, run: &TextRun) -> String {
let mut xml = String::new();
if run.text == "\n" {
return "<w:r><w:br/></w:r>".to_string();
}
xml.push_str("<w:r>");
xml.push_str("<w:rPr>");
xml.push_str(&format!(
r#"<w:rFonts w:ascii="{}" w:hAnsi="{}" w:eastAsia="{}"/>"#,
run.style.font_family, run.style.font_family, run.style.font_family
));
xml.push_str(&format!(r#"<w:sz w:val="{}"/>"#, run.style.font_size));
xml.push_str(&format!(r#"<w:szCs w:val="{}"/>"#, run.style.font_size));
if run.style.bold {
xml.push_str("<w:b/>");
}
if run.style.italic {
xml.push_str("<w:i/>");
}
if run.style.underline {
xml.push_str(r#"<w:u w:val="single"/>"#);
}
if run.style.strike {
xml.push_str("<w:strike/>");
}
if let Some(color) = &run.style.color {
xml.push_str(&format!(r#"<w:color w:val="{}"/>"#, color));
}
if let Some(bg_color) = &run.style.background_color {
xml.push_str(&format!(
r#"<w:highlight w:val="{}"/>"#,
self.map_highlight_color(bg_color)
));
}
xml.push_str("</w:rPr>");
xml.push_str(&format!(
r#"<w:t xml:space="preserve">{}</w:t>"#,
self.escape_xml(&run.text)
));
xml.push_str("</w:r>");
xml
}
fn map_highlight_color(&self, color: &str) -> &'static str {
let color_upper = color.to_uppercase();
match color_upper.as_str() {
"FFFF00" | "YELLOW" => "yellow",
"00FF00" | "GREEN" => "green",
"00FFFF" | "CYAN" => "cyan",
"FF00FF" | "MAGENTA" => "magenta",
"0000FF" | "BLUE" => "blue",
"FF0000" | "RED" => "red",
"000000" | "BLACK" => "black",
"808080" | "GRAY" => "darkGray",
"C0C0C0" | "LIGHTGRAY" => "lightGray",
_ => "yellow", }
}
fn normalize_whitespace(text: &str) -> String {
let has_leading_space = text.starts_with(|c: char| c.is_whitespace());
let has_trailing_space = text.ends_with(|c: char| c.is_whitespace());
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return String::new();
}
let mut result = words.join(" ");
if has_leading_space {
result.insert(0, ' ');
}
if has_trailing_space {
result.push(' ');
}
result
}
fn escape_xml(&self, text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
}
pub fn convert(html: &str) -> String {
let converter = HtmlToParagraph::new();
converter.convert(html)
}
pub fn convert_with_font(html: &str, font: String, font_size: u32) -> String {
let converter = HtmlToParagraph::new()
.with_font(font)
.with_font_size(font_size);
converter.convert(html)
}