use std::path::Path;
use oxml_cli_support::{default_output_path, json_envelope};
use rdocx::Document;
use serde_json::{Value, json};
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 = inspect_json(file, &doc, 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(())
}
fn inspect_json(file: &Path, doc: &Document, style_ids: Vec<String>) -> Result<Value> {
Ok(json_envelope(json!({
"file": file.display().to_string(),
"paragraphs": doc.paragraph_count(),
"tables": doc.table_count(),
"content_elements": doc.content_count(),
"metadata": {
"title": doc.title(),
"author": doc.author(),
"subject": doc.subject(),
"keywords": doc.keywords(),
},
"styles_used": style_ids,
}))?)
}
pub fn text(file: &Path) -> Result<()> {
let doc = Document::open(file)?;
print!("{}", doc.text());
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 => default_output_path(file, 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_deterministic(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 mut page_count = 0;
while let Some(png) = doc.render_page_to_png_deterministic(page_count, dpi)? {
let out_path = out_dir.join(format!("{stem}_page{}.png", page_count + 1));
std::fs::write(&out_path, &png)?;
println!(
"Page {} -> {} ({} bytes)",
page_count + 1,
out_path.display(),
png.len()
);
page_count += 1;
}
println!("Rendered {page_count} page(s) at {dpi} DPI");
}
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 = oxml_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 = oxml_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())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inspect_json_uses_the_shared_schema_one_envelope() {
let document = Document::new();
let styles = vec!["Heading1".to_owned(), "Normal".to_owned()];
let value = inspect_json(Path::new("input.docx"), &document, styles.clone()).unwrap();
assert_eq!(
value,
json!({
"schema": 1,
"file": "input.docx",
"paragraphs": document.paragraph_count(),
"tables": document.table_count(),
"content_elements": document.content_count(),
"metadata": {
"title": document.title(),
"author": document.author(),
"subject": document.subject(),
"keywords": document.keywords(),
},
"styles_used": styles,
})
);
}
#[test]
fn convert_without_output_uses_the_default_extension_path() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let input = std::env::temp_dir().join(format!(
"rdocx_cli_default_convert_{}_{unique}.docx",
std::process::id()
));
let expected_output = input.with_extension("md");
let mut document = Document::new();
document.add_paragraph("Default path regression");
document.save(&input).unwrap();
convert(&input, "md", None, 96, None).unwrap();
let converted = std::fs::read_to_string(&expected_output);
std::fs::remove_file(input).unwrap();
std::fs::remove_file(expected_output).ok();
assert_eq!(converted.unwrap(), "Default path regression\n\n");
}
}