use std::path::Path;
use rdocx::Document;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
pub fn inspect(file: &Path, json: bool) -> Result<()> {
let doc = Document::open(file)?;
let paragraph_count = doc.paragraph_count();
let table_count = doc.table_count();
let content_count = doc.content_count();
let title = doc.title().map(|s| s.to_string());
let author = doc.author().map(|s| s.to_string());
let subject = doc.subject().map(|s| s.to_string());
let keywords = doc.keywords().map(|s| s.to_string());
let mut style_ids: Vec<String> = Vec::new();
for para in doc.paragraphs() {
if let Some(style) = para.style_id() {
let s = style.to_string();
if !style_ids.contains(&s) {
style_ids.push(s);
}
}
}
if json {
let obj = serde_json::json!({
"file": file.display().to_string(),
"paragraphs": paragraph_count,
"tables": table_count,
"content_elements": content_count,
"metadata": {
"title": title,
"author": author,
"subject": subject,
"keywords": keywords,
},
"styles_used": style_ids,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("File: {}", file.display());
println!("Paragraphs: {paragraph_count}");
println!("Tables: {table_count}");
println!("Content elements: {content_count}");
println!();
println!("Metadata:");
if let Some(t) = &title {
println!(" Title: {t}");
}
if let Some(a) = &author {
println!(" Author: {a}");
}
if let Some(s) = &subject {
println!(" Subject: {s}");
}
if let Some(k) = &keywords {
println!(" Keywords: {k}");
}
if title.is_none() && author.is_none() && subject.is_none() && keywords.is_none() {
println!(" (none)");
}
println!();
println!("Styles used:");
if style_ids.is_empty() {
println!(" (none)");
} else {
for sid in &style_ids {
println!(" - {sid}");
}
}
}
Ok(())
}
pub fn text(file: &Path) -> Result<()> {
let doc = Document::open(file)?;
for para in doc.paragraphs() {
println!("{}", para.text());
}
for table in doc.tables() {
for row_idx in 0..table.row_count() {
let Some(row) = table.row(row_idx) else {
continue;
};
let cells: Vec<String> = (0..row.cell_count())
.filter_map(|i| row.cell(i))
.map(|c| c.text().replace('\n', " "))
.collect();
println!("{}", cells.join("\t"));
}
}
Ok(())
}
pub fn convert(
file: &Path,
to: &str,
output: Option<&Path>,
dpi: u32,
font_dir: Option<&Path>,
) -> Result<()> {
let doc = Document::open(file)?;
let default_ext = match to {
"pdf" => "pdf",
"html" => "html",
"md" | "markdown" => "md",
"png" => "png",
other => {
return Err(format!("Unknown format: {other}. Supported: pdf, html, md, png").into());
}
};
let output_path = match output {
Some(p) => p.to_path_buf(),
None => file.with_extension(default_ext),
};
match to {
"pdf" => {
let bytes = if let Some(dir) = font_dir {
let font_files = Document::load_fonts_from_dir(dir);
let font_refs: Vec<(&str, &[u8])> = font_files
.iter()
.map(|f| (f.family.as_str(), f.data.as_slice()))
.collect();
doc.to_pdf_with_fonts(&font_refs)?
} else {
doc.to_pdf()?
};
std::fs::write(&output_path, bytes)?;
}
"html" => {
let html = doc.to_html();
std::fs::write(&output_path, html)?;
}
"md" | "markdown" => {
let md = doc.to_markdown();
std::fs::write(&output_path, md)?;
}
"png" => {
let pages = doc.render_all_pages(dpi as f64)?;
if pages.len() == 1 {
std::fs::write(&output_path, &pages[0])?;
} else {
let stem = output_path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let parent = output_path.parent().unwrap_or(Path::new("."));
for (i, page) in pages.iter().enumerate() {
let page_path = parent.join(format!("{stem}_{:03}.png", i + 1));
std::fs::write(&page_path, page)?;
}
println!(
"Written {} pages to {}/{stem}_NNN.png",
pages.len(),
parent.display()
);
return Ok(());
}
}
_ => unreachable!(),
}
println!("Written to {}", output_path.display());
Ok(())
}
pub fn diff(file_a: &Path, file_b: &Path) -> Result<()> {
let doc_a = Document::open(file_a)?;
let doc_b = Document::open(file_b)?;
let paras_a: Vec<String> = doc_a.paragraphs().iter().map(|p| p.text()).collect();
let paras_b: Vec<String> = doc_b.paragraphs().iter().map(|p| p.text()).collect();
println!(
"--- {} ({} paragraphs, {} tables)",
file_a.display(),
doc_a.paragraph_count(),
doc_a.table_count()
);
println!(
"+++ {} ({} paragraphs, {} tables)",
file_b.display(),
doc_b.paragraph_count(),
doc_b.table_count()
);
println!();
let lcs = compute_lcs(¶s_a, ¶s_b);
let mut i = 0;
let mut j = 0;
let mut k = 0;
while k < lcs.len() {
while i < paras_a.len() && paras_a[i] != lcs[k] {
println!("- [{}] {}", i + 1, paras_a[i]);
i += 1;
}
while j < paras_b.len() && paras_b[j] != lcs[k] {
println!("+ [{}] {}", j + 1, paras_b[j]);
j += 1;
}
i += 1;
j += 1;
k += 1;
}
while i < paras_a.len() {
println!("- [{}] {}", i + 1, paras_a[i]);
i += 1;
}
while j < paras_b.len() {
println!("+ [{}] {}", j + 1, paras_b[j]);
j += 1;
}
let changes = paras_a.len() + paras_b.len() - 2 * lcs.len();
if changes == 0 {
println!("(no differences in paragraph text)");
} else {
println!("\n{changes} paragraph(s) differ.");
}
Ok(())
}
fn compute_lcs(a: &[String], b: &[String]) -> Vec<String> {
let m = a.len();
let n = b.len();
let mut dp = vec![vec![0u32; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
dp[i][j] = if a[i - 1] == b[j - 1] {
dp[i - 1][j - 1] + 1
} else {
dp[i - 1][j].max(dp[i][j - 1])
};
}
}
let mut result = Vec::new();
let mut i = m;
let mut j = n;
while i > 0 && j > 0 {
if a[i - 1] == b[j - 1] {
result.push(a[i - 1].clone());
i -= 1;
j -= 1;
} else if dp[i - 1][j] >= dp[i][j - 1] {
i -= 1;
} else {
j -= 1;
}
}
result.reverse();
result
}
pub fn replace(file: &Path, placeholder: &str, value: &str, output: &Path) -> Result<()> {
let mut doc = Document::open(file)?;
let count = doc.replace_text(placeholder, value);
doc.save(output)?;
println!("Replaced {count} occurrence(s) of \"{placeholder}\" -> \"{value}\"");
println!("Written to {}", output.display());
Ok(())
}
pub fn render(file: &Path, output_dir: Option<&Path>, dpi: f64, page: Option<usize>) -> Result<()> {
let doc = Document::open(file)?;
let out_dir = output_dir.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(out_dir)?;
let stem = file.file_stem().unwrap_or_default().to_string_lossy();
if let Some(page_idx) = page {
let png = doc
.render_page_to_png(page_idx, dpi)?
.ok_or_else(|| format!("Page {page_idx} not found"))?;
let out_path = out_dir.join(format!("{stem}_page{}.png", page_idx + 1));
std::fs::write(&out_path, &png)?;
println!(
"Page {} -> {} ({} bytes)",
page_idx + 1,
out_path.display(),
png.len()
);
} else {
let pages = doc.render_all_pages(dpi)?;
for (i, png) in pages.iter().enumerate() {
let out_path = out_dir.join(format!("{stem}_page{}.png", i + 1));
std::fs::write(&out_path, png)?;
println!(
"Page {} -> {} ({} bytes)",
i + 1,
out_path.display(),
png.len()
);
}
println!("Rendered {} page(s) at {dpi} DPI", pages.len());
}
Ok(())
}
pub fn validate(file: &Path) -> Result<bool> {
let doc = Document::open(file)?;
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let package = rdocx_opc::OpcPackage::open(file)?;
if let Some(doc_part) = package.main_document_part() {
if package.get_part(&doc_part).is_none() {
errors.push(format!("main document part {doc_part} is missing"));
}
if let Some(rels) = package.get_part_rels(&doc_part) {
for rel in &rels.items {
if rel.target_mode.as_deref() == Some("External") {
continue;
}
let target = rdocx_opc::OpcPackage::resolve_rel_target(&doc_part, &rel.target);
if package.get_part(&target).is_none() {
errors.push(format!(
"relationship {} points at missing part {target}",
rel.id
));
}
}
}
} else {
errors.push("package declares no main document relationship".to_string());
}
for part_name in package.parts.keys() {
if package.content_types.content_type_for(part_name).is_none() {
errors.push(format!("part {part_name} has no declared content type"));
}
}
if doc.content_count() == 0 {
warnings.push("Document has no content (no paragraphs or tables)".to_string());
}
let empty_count = doc
.paragraphs()
.iter()
.filter(|p| p.text().trim().is_empty())
.count();
if empty_count > 0 {
warnings.push(format!("{empty_count} empty paragraph(s) found"));
}
let mut prev_level: Option<u32> = None;
for (level, _) in doc.headings() {
if let Some(prev) = prev_level
&& level > prev + 1
{
warnings.push(format!(
"Heading level gap: Heading{prev} -> Heading{level} (skipped level(s))"
));
}
prev_level = Some(level);
}
if doc.title().is_none() {
warnings.push("Missing document title".to_string());
}
if doc.author().is_none() {
warnings.push("Missing document author".to_string());
}
if errors.is_empty() && warnings.is_empty() {
println!("OK — no issues found in {}", file.display());
return Ok(true);
}
if !errors.is_empty() {
println!("{} error(s) in {}:", errors.len(), file.display());
for (i, issue) in errors.iter().enumerate() {
println!(" {}. {issue}", i + 1);
}
}
if !warnings.is_empty() {
println!("{} warning(s) in {}:", warnings.len(), file.display());
for (i, issue) in warnings.iter().enumerate() {
println!(" {}. {issue}", i + 1);
}
}
Ok(errors.is_empty())
}