use std::collections::HashMap;
use rdocx_oxml::document::{BodyContent, CT_Body};
use rdocx_oxml::numbering::CT_Numbering;
use rdocx_oxml::properties::CT_PPr;
use rdocx_oxml::styles::CT_Styles;
use rdocx_oxml::table::{CT_Tbl, CellContent};
use rdocx_oxml::text::{BreakType, CT_P, CT_R, RunContent};
pub(crate) fn emit_markdown(
body: &CT_Body,
styles: &CT_Styles,
numbering: Option<&CT_Numbering>,
hyperlink_urls: &HashMap<String, String>,
) -> String {
let mut out = String::new();
for content in &body.content {
match content {
BodyContent::Paragraph(p) => {
emit_paragraph(&mut out, p, styles, numbering, hyperlink_urls);
}
BodyContent::Table(tbl) => {
emit_table(&mut out, tbl, hyperlink_urls);
}
BodyContent::RawXml(_) => {}
}
}
out
}
fn detect_heading_level(ppr: Option<&CT_PPr>, styles: &CT_Styles) -> Option<u32> {
let ppr = ppr?;
if let Some(lvl) = ppr.outline_lvl {
return Some(lvl + 1);
}
let style_id = ppr.style_id.as_deref()?;
if let Some(level) = style_id.strip_prefix("Heading")
&& let Ok(n) = level.parse::<u32>()
&& (1..=6).contains(&n)
{
return Some(n);
}
if let Some(style) = styles.get_by_id(style_id)
&& let Some(spr) = &style.ppr
&& let Some(lvl) = spr.outline_lvl
{
return Some(lvl + 1);
}
None
}
fn detect_list(para: &CT_P, numbering: Option<&CT_Numbering>) -> Option<(bool, u32)> {
let ppr = para.properties.as_ref()?;
let num_id = ppr.num_id?;
let ilvl = ppr.num_ilvl.unwrap_or(0);
if num_id == 0 {
return None;
}
let numbering = numbering?;
let abstract_id = numbering
.nums
.iter()
.find(|n| n.num_id == num_id)
.map(|n| n.abstract_num_id)?;
let abstract_num = numbering
.abstract_nums
.iter()
.find(|a| a.abstract_num_id == abstract_id)?;
let level = abstract_num.levels.iter().find(|l| l.ilvl == ilvl)?;
let is_ordered = !matches!(
level.num_fmt,
Some(rdocx_oxml::numbering::ST_NumberFormat::Bullet)
);
Some((is_ordered, ilvl))
}
fn emit_paragraph(
out: &mut String,
para: &CT_P,
styles: &CT_Styles,
numbering: Option<&CT_Numbering>,
hyperlink_urls: &HashMap<String, String>,
) {
let heading_level = detect_heading_level(para.properties.as_ref(), styles);
let list_info = detect_list(para, numbering);
let text = collect_paragraph_text(para, hyperlink_urls);
if text.trim().is_empty() && heading_level.is_none() && list_info.is_none() {
out.push('\n');
return;
}
if let Some(level) = heading_level {
let hashes = "#".repeat(level.min(6) as usize);
out.push_str(&hashes);
out.push(' ');
out.push_str(text.trim());
out.push_str("\n\n");
} else if let Some((is_ordered, ilvl)) = list_info {
let indent = " ".repeat(ilvl as usize);
if is_ordered {
out.push_str(&format!("{indent}1. {}\n", text.trim()));
} else {
out.push_str(&format!("{indent}- {}\n", text.trim()));
}
} else {
out.push_str(&text);
out.push_str("\n\n");
}
}
fn collect_paragraph_text(para: &CT_P, hyperlink_urls: &HashMap<String, String>) -> String {
let mut out = String::new();
let mut hyperlink_map: HashMap<usize, &str> = HashMap::new();
for hl in ¶.hyperlinks {
if let Some(rel_id) = &hl.rel_id
&& let Some(url) = hyperlink_urls
.get(rel_id)
.map(String::as_str)
.and_then(crate::sanitize::safe_url)
{
for i in hl.run_start..hl.run_end {
hyperlink_map.insert(i, url);
}
}
}
let mut current_link: Option<&str> = None;
let mut link_text = String::new();
for (run_idx, run) in para.runs.iter().enumerate() {
let in_link = hyperlink_map.get(&run_idx).copied();
match (current_link, in_link) {
(Some(url), None) => {
out.push_str(&format!("[{}]({})", link_text, url));
link_text.clear();
current_link = None;
}
(Some(old_url), Some(new_url)) if old_url != new_url => {
out.push_str(&format!("[{}]({})", link_text, old_url));
link_text.clear();
current_link = Some(new_url);
}
(None, Some(url)) => {
current_link = Some(url);
}
_ => {}
}
let run_text = collect_run_text(run);
if current_link.is_some() {
link_text.push_str(&run_text);
} else {
out.push_str(&run_text);
}
}
if let Some(url) = current_link {
out.push_str(&format!("[{}]({})", link_text, url));
}
out
}
fn collect_run_text(run: &CT_R) -> String {
let mut raw = String::new();
for content in &run.content {
match content {
RunContent::Text(t) => raw.push_str(&t.text),
RunContent::Tab => raw.push('\t'),
RunContent::Break(bt) => match bt {
BreakType::Line => raw.push_str(" \n"),
BreakType::Page => raw.push_str("\n---\n"),
BreakType::Column => raw.push_str(" \n"),
},
RunContent::Drawing(_)
| RunContent::Field { .. }
| RunContent::FootnoteRef { .. }
| RunContent::EndnoteRef { .. } => {}
}
}
if raw.is_empty() {
return raw;
}
let rpr = run.properties.as_ref();
let bold = rpr.is_some_and(|r| r.bold == Some(true));
let italic = rpr.is_some_and(|r| r.italic == Some(true));
let strike = rpr.is_some_and(|r| r.strike == Some(true) || r.dstrike == Some(true));
if bold && italic {
format!("***{raw}***")
} else if bold {
format!("**{raw}**")
} else if italic {
format!("*{raw}*")
} else if strike {
format!("~~{raw}~~")
} else {
raw
}
}
fn emit_table(out: &mut String, tbl: &CT_Tbl, hyperlink_urls: &HashMap<String, String>) {
if tbl.rows.is_empty() {
return;
}
let mut rows: Vec<Vec<String>> = Vec::new();
let mut max_cols = 0;
for row in &tbl.rows {
let mut cells: Vec<String> = Vec::new();
for cell in &row.cells {
let text = collect_cell_text(cell, hyperlink_urls);
cells.push(text);
}
if cells.len() > max_cols {
max_cols = cells.len();
}
rows.push(cells);
}
if rows.is_empty() || max_cols == 0 {
return;
}
for row in &mut rows {
while row.len() < max_cols {
row.push(String::new());
}
}
out.push('|');
for cell in &rows[0] {
out.push_str(&format!(" {} |", cell.trim()));
}
out.push('\n');
out.push('|');
for _ in 0..max_cols {
out.push_str(" --- |");
}
out.push('\n');
for row in rows.iter().skip(1) {
out.push('|');
for cell in row {
out.push_str(&format!(" {} |", cell.trim()));
}
out.push('\n');
}
out.push('\n');
}
fn collect_cell_text(
cell: &rdocx_oxml::table::CT_Tc,
hyperlink_urls: &HashMap<String, String>,
) -> String {
let mut parts = Vec::new();
for content in &cell.content {
match content {
CellContent::Paragraph(p) => {
let text = collect_paragraph_text(p, hyperlink_urls);
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
parts.push(trimmed);
}
}
CellContent::Table(_) => {
parts.push("(nested table)".to_string());
}
}
}
parts.join(" ")
}