#![allow(clippy::unused_self, clippy::uninlined_format_args)]
use std::path::{Path, PathBuf};
use std::time::Instant;
use async_trait::async_trait;
use super::traits::{ConverterMetadata, DocumentConverter};
use crate::Result;
use crate::types::{
ConversionOptions, ConversionOutput, ConversionResult, ConversionStatistics, DocumentMetadata,
FileFormat, OutputFormat, OutputMetadata,
};
#[derive(Debug)]
pub struct DocxConverter;
#[cfg(feature = "office")]
struct DocxRender {
outputs: Vec<ConversionOutput>,
page_count: usize,
table_count: usize,
}
impl DocxConverter {
pub fn new() -> Self {
Self
}
#[cfg(feature = "pdf-to-image")]
async fn convert_to_images(
&self,
path: &Path,
format: crate::types::ImageFormat,
_quality: u8,
dpi: u32,
_options: &ConversionOptions,
) -> Result<Vec<ConversionOutput>> {
use std::process::Command;
use tokio::fs;
eprintln!("🖼️ Converting DOCX to images (DOCX → PDF → Images)...");
let temp_dir =
std::env::temp_dir().join(format!("transmutation_docx_{}", std::process::id()));
fs::create_dir_all(&temp_dir).await?;
eprintln!(" [1/2] DOCX → PDF (LibreOffice --headless)...");
let (libreoffice_cmd, install_msg) = if cfg!(target_os = "windows") {
(
"soffice.exe",
"Install LibreOffice from https://www.libreoffice.org/download/",
)
} else if cfg!(target_os = "macos") {
(
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"Install: brew install libreoffice",
)
} else {
("libreoffice", "Install: sudo apt install libreoffice")
};
let output = Command::new(libreoffice_cmd)
.arg("--headless")
.arg("--convert-to")
.arg("pdf")
.arg("--outdir")
.arg(&temp_dir)
.arg(path)
.output()
.map_err(|e| {
crate::TransmutationError::engine_error(
"libreoffice",
format!("Failed to run LibreOffice: {}.\n{}", e, install_msg),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let _ = fs::remove_dir_all(&temp_dir).await;
return Err(crate::TransmutationError::engine_error(
"libreoffice",
format!("LibreOffice failed: {}", stderr),
));
}
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("document");
let pdf_path = temp_dir.join(format!("{}.pdf", filename));
if !pdf_path.exists() {
let _ = fs::remove_dir_all(&temp_dir).await;
return Err(crate::TransmutationError::engine_error(
"libreoffice",
"PDF not generated by LibreOffice".to_string(),
));
}
let pdf_size = pdf_path.metadata()?.len();
eprintln!(" ✓ PDF: {} KB", pdf_size / 1024);
eprintln!(" [2/2] PDF → Images (pdftoppm @ {} DPI)...", dpi);
let format_flag = match format {
crate::types::ImageFormat::Png => "png",
crate::types::ImageFormat::Jpeg => "jpeg",
crate::types::ImageFormat::Webp => "png",
};
let (pdftoppm_cmd, pdftoppm_install) = if cfg!(target_os = "windows") {
("pdftoppm.exe", "Install poppler: choco install poppler")
} else if cfg!(target_os = "macos") {
("pdftoppm", "Install: brew install poppler")
} else {
("pdftoppm", "Install: sudo apt install poppler-utils")
};
let output = Command::new(pdftoppm_cmd)
.arg(format!("-{}", format_flag))
.arg("-r")
.arg(dpi.to_string())
.arg(&pdf_path)
.arg(temp_dir.join("page"))
.output()
.map_err(|e| {
let _ = std::fs::remove_dir_all(&temp_dir);
crate::TransmutationError::engine_error(
"pdftoppm",
format!("Failed: {}.\n{}", e, pdftoppm_install),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let _ = fs::remove_dir_all(&temp_dir).await;
return Err(crate::TransmutationError::engine_error(
"pdftoppm",
format!("pdftoppm failed: {}", stderr),
));
}
let mut outputs = Vec::new();
let mut entries = fs::read_dir(&temp_dir).await?;
let mut image_files = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let entry_path = entry.path();
if entry_path.extension().and_then(|e| e.to_str()) == Some(format_flag) {
image_files.push(entry_path);
}
}
image_files.sort();
eprintln!(" ✓ Rendered {} pages", image_files.len());
for (idx, image_path) in image_files.iter().enumerate() {
let image_data = fs::read(&image_path).await?;
let size_bytes = image_data.len() as u64;
outputs.push(ConversionOutput {
page_number: idx + 1,
data: image_data,
metadata: OutputMetadata {
size_bytes,
chunk_count: 1,
token_count: None,
},
});
}
let _ = fs::remove_dir_all(&temp_dir).await;
eprintln!("✅ DOCX → {} images complete!", outputs.len());
Ok(outputs)
}
#[cfg(feature = "office")]
async fn convert_to_markdown(
&self,
path: &Path,
options: &ConversionOptions,
) -> Result<DocxRender> {
eprintln!("📄 Reading DOCX file with docx-rs...");
let file_data = tokio::fs::read(path).await?;
let docx = docx_rs::read_docx(&file_data).map_err(|e| {
crate::TransmutationError::engine_error(
"docx-rs",
format!("Failed to parse DOCX: {:?}", e),
)
})?;
eprintln!("✓ DOCX parsed successfully");
let page_count = count_pages(&file_data);
let mut blocks: Vec<String> = Vec::new();
let mut list_buf: Vec<String> = Vec::new();
let mut counters: Vec<usize> = Vec::new();
let mut active_num: Option<usize> = None;
let mut table_count = 0usize;
for child in &docx.document.children {
match child {
docx_rs::DocumentChild::Paragraph(para) => {
if let Some((num_id, level)) = paragraph_numbering(para) {
if active_num != Some(num_id) {
counters.clear();
active_num = Some(num_id);
}
let text = paragraph_plain_text(para);
let indent = " ".repeat(level);
let marker = match list_kind(&docx.numberings, num_id, level) {
ListKind::Bullet => "- ".to_string(),
ListKind::Ordered => {
format!("{}. ", next_ordered_number(&mut counters, level))
}
};
list_buf.push(format!("{}{}{}", indent, marker, text));
} else {
flush_list(&mut blocks, &mut list_buf);
active_num = None;
counters.clear();
let text = paragraph_plain_text(para);
if text.is_empty() {
continue;
}
let heading = para
.property
.style
.as_ref()
.and_then(|s| heading_level(&s.val));
match heading {
Some(level) => blocks.push(format!("{} {}", "#".repeat(level), text)),
None => blocks.push(text),
}
}
}
docx_rs::DocumentChild::Table(table) => {
flush_list(&mut blocks, &mut list_buf);
active_num = None;
counters.clear();
let md = table_to_markdown(table);
if !md.is_empty() {
table_count += 1;
blocks.push(md);
}
}
_ => {}
}
}
flush_list(&mut blocks, &mut list_buf);
if options.split_pages && page_count > 1 && blocks.len() > 1 {
eprintln!("📄 Splitting DOCX into {} pages...", page_count);
let outputs = distribute_blocks(&blocks, page_count)
.into_iter()
.enumerate()
.map(|(idx, page_blocks)| make_output(idx + 1, page_blocks.join("\n\n")))
.collect();
return Ok(DocxRender {
outputs,
page_count,
table_count,
});
}
let markdown = blocks.join("\n\n");
eprintln!("✓ Converted to Markdown: {} chars", markdown.len());
Ok(DocxRender {
outputs: vec![make_output(0, markdown)],
page_count,
table_count,
})
}
}
#[cfg(feature = "office")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ListKind {
Bullet,
Ordered,
}
#[cfg(feature = "office")]
fn flush_list(blocks: &mut Vec<String>, list_buf: &mut Vec<String>) {
if !list_buf.is_empty() {
blocks.push(list_buf.join("\n"));
list_buf.clear();
}
}
#[cfg(feature = "office")]
fn heading_level(style_id: &str) -> Option<usize> {
let normalized: String = style_id
.chars()
.filter(|c| !c.is_whitespace() && *c != '-' && *c != '_')
.collect::<String>()
.to_ascii_lowercase();
if normalized == "title" {
return Some(1);
}
let rest = normalized.strip_prefix("heading")?;
let level: usize = rest.parse().ok()?;
(1..=9).contains(&level).then_some(level)
}
#[cfg(feature = "office")]
const MAX_LIST_LEVEL: usize = 8;
#[cfg(feature = "office")]
fn paragraph_numbering(para: &docx_rs::Paragraph) -> Option<(usize, usize)> {
let np = para.property.numbering_property.as_ref()?;
let id = np.id.as_ref()?.id;
let level = np
.level
.as_ref()
.map(|l| l.val)
.unwrap_or(0)
.min(MAX_LIST_LEVEL);
Some((id, level))
}
#[cfg(feature = "office")]
fn list_kind(numberings: &docx_rs::Numberings, num_id: usize, level: usize) -> ListKind {
let abstract_id = numberings
.numberings
.iter()
.find(|n| n.id == num_id)
.map(|n| n.abstract_num_id);
if let Some(abstract_id) = abstract_id {
if let Some(abstract_num) = numberings
.abstract_nums
.iter()
.find(|a| a.id == abstract_id)
{
let level_def = abstract_num
.levels
.iter()
.find(|l| l.level == level)
.or_else(|| abstract_num.levels.first());
if let Some(level_def) = level_def {
if level_def.format.val.eq_ignore_ascii_case("bullet") {
return ListKind::Bullet;
}
return ListKind::Ordered;
}
}
}
ListKind::Bullet
}
#[cfg(feature = "office")]
fn next_ordered_number(counters: &mut Vec<usize>, level: usize) -> usize {
if counters.len() > level + 1 {
counters.truncate(level + 1);
}
while counters.len() < level + 1 {
counters.push(0);
}
counters[level] += 1;
counters[level]
}
#[cfg(feature = "office")]
fn paragraph_plain_text(para: &docx_rs::Paragraph) -> String {
let mut text = String::new();
collect_children_text(¶.children, &mut text);
text.trim().to_string()
}
#[cfg(feature = "office")]
fn collect_children_text(children: &[docx_rs::ParagraphChild], text: &mut String) {
use docx_rs::{ParagraphChild, RunChild};
for child in children {
match child {
ParagraphChild::Run(run) => {
for run_child in &run.children {
match run_child {
RunChild::Text(t) => text.push_str(&t.text),
RunChild::Tab(_) => text.push('\t'),
_ => {}
}
}
}
ParagraphChild::Hyperlink(link) => collect_children_text(&link.children, text),
_ => {}
}
}
}
#[cfg(feature = "office")]
fn table_to_markdown(table: &docx_rs::Table) -> String {
use docx_rs::{TableCellContent, TableChild, TableRowChild};
let mut rows: Vec<Vec<String>> = Vec::new();
for row_child in &table.rows {
let TableChild::TableRow(row) = row_child;
let mut cells: Vec<String> = Vec::new();
for cell_child in &row.cells {
let TableRowChild::TableCell(cell) = cell_child;
let mut parts: Vec<String> = Vec::new();
for content in &cell.children {
if let TableCellContent::Paragraph(p) = content {
let text = paragraph_plain_text(p);
if !text.is_empty() {
parts.push(text);
}
}
}
let text = parts.join(" ").replace('\n', " ").replace('|', "\\|");
cells.push(text.trim().to_string());
}
rows.push(cells);
}
if rows.is_empty() {
return String::new();
}
let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if col_count == 0 {
return String::new();
}
let render_row = |cells: &[String]| -> String {
let mut c = cells.to_vec();
c.resize(col_count, String::new());
format!("| {} |", c.join(" | "))
};
let mut out = String::new();
out.push_str(&render_row(&rows[0]));
out.push('\n');
out.push('|');
for _ in 0..col_count {
out.push_str(" --- |");
}
for row in &rows[1..] {
out.push('\n');
out.push_str(&render_row(row));
}
out
}
#[cfg(feature = "office")]
fn count_pages(file_data: &[u8]) -> usize {
use std::io::Read;
const MAX_DOCUMENT_XML_BYTES: u64 = 128 * 1024 * 1024;
fn markers(file_data: &[u8]) -> Option<usize> {
let reader = std::io::Cursor::new(file_data);
let mut archive = zip::ZipArchive::new(reader).ok()?;
let document = archive.by_name("word/document.xml").ok()?;
let mut buf = Vec::new();
document
.take(MAX_DOCUMENT_XML_BYTES)
.read_to_end(&mut buf)
.ok()?;
let xml = String::from_utf8_lossy(&buf);
let rendered = xml.matches("lastRenderedPageBreak").count();
if rendered > 0 {
return Some(rendered);
}
Some(xml.matches("w:type=\"page\"").count())
}
markers(file_data).unwrap_or(0) + 1
}
#[cfg(feature = "office")]
fn clean_markdown(mut markdown: String) -> String {
while markdown.contains("\n\n\n") {
markdown = markdown.replace("\n\n\n", "\n\n");
}
markdown.trim().to_string()
}
#[cfg(feature = "office")]
fn make_output(page_number: usize, markdown: String) -> ConversionOutput {
let markdown = clean_markdown(markdown);
let token_count = markdown.len() / 4;
let data = markdown.into_bytes();
let size_bytes = data.len() as u64;
ConversionOutput {
page_number,
data,
metadata: OutputMetadata {
size_bytes,
chunk_count: 1,
token_count: Some(token_count),
},
}
}
#[cfg(feature = "office")]
fn distribute_blocks(blocks: &[String], pages: usize) -> Vec<Vec<String>> {
let pages = pages.clamp(1, blocks.len().max(1));
let per_page = blocks.len().div_ceil(pages).max(1);
let mut result: Vec<Vec<String>> = blocks
.chunks(per_page)
.map(|chunk| chunk.to_vec())
.collect();
result.retain(|page| !page.is_empty());
if result.is_empty() {
result.push(Vec::new());
}
result
}
impl Default for DocxConverter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DocumentConverter for DocxConverter {
fn supported_formats(&self) -> Vec<FileFormat> {
vec![FileFormat::Docx]
}
fn output_formats(&self) -> Vec<OutputFormat> {
vec![OutputFormat::Markdown {
split_pages: false,
optimize_for_llm: true,
}]
}
async fn convert(
&self,
input: &Path,
output_format: OutputFormat,
options: ConversionOptions,
) -> Result<ConversionResult> {
let start_time = Instant::now();
let input_size = tokio::fs::metadata(input).await?.len();
let (content, page_count, table_count) = match output_format {
OutputFormat::Markdown { .. } => {
#[cfg(feature = "office")]
{
let rendered = self.convert_to_markdown(input, &options).await?;
(rendered.outputs, rendered.page_count, rendered.table_count)
}
#[cfg(not(feature = "office"))]
{
return Err(crate::TransmutationError::InvalidOptions(
"DOCX conversion requires office feature".to_string(),
));
}
}
OutputFormat::Image {
format: _format,
quality: _quality,
dpi: _dpi,
} => {
#[cfg(feature = "pdf-to-image")]
{
let outputs = self
.convert_to_images(input, _format, _quality, _dpi, &options)
.await?;
let pages = outputs.len();
(outputs, pages, 0)
}
#[cfg(not(feature = "pdf-to-image"))]
{
return Err(crate::TransmutationError::InvalidOptions(
"DOCX to image requires pdf-to-image feature (uses LibreOffice + pdftoppm)"
.to_string(),
));
}
}
_ => {
return Err(crate::TransmutationError::InvalidOptions(format!(
"Unsupported output format for DOCX: {:?}",
output_format
)));
}
};
let output_size: u64 = content.iter().map(|c| c.metadata.size_bytes).sum();
let metadata = DocumentMetadata {
title: None, author: None,
created: None,
modified: None,
page_count,
language: None,
custom: std::collections::HashMap::new(),
};
let duration = start_time.elapsed();
let statistics = ConversionStatistics {
input_size_bytes: input_size,
output_size_bytes: output_size,
duration,
pages_processed: page_count,
tables_extracted: table_count,
images_extracted: 0,
cache_hit: false,
};
Ok(ConversionResult {
input_path: PathBuf::from(input),
input_format: FileFormat::Docx,
output_format,
content,
metadata,
statistics,
})
}
fn metadata(&self) -> ConverterMetadata {
ConverterMetadata {
name: "DOCX Converter".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
description: "Pure Rust DOCX to Markdown converter".to_string(),
external_deps: vec!["docx-rs".to_string()],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_docx_converter_creation() {
let converter = DocxConverter::new();
assert_eq!(converter.supported_formats(), vec![FileFormat::Docx]);
}
#[test]
fn test_docx_converter_metadata() {
let converter = DocxConverter::new();
let meta = converter.metadata();
assert_eq!(meta.name, "DOCX Converter");
assert!(meta.external_deps.contains(&"docx-rs".to_string()));
}
#[cfg(feature = "office")]
#[test]
fn test_heading_level_detection() {
assert_eq!(heading_level("Heading1"), Some(1));
assert_eq!(heading_level("Heading3"), Some(3));
assert_eq!(heading_level("heading 2"), Some(2));
assert_eq!(heading_level("Heading-4"), Some(4));
assert_eq!(heading_level("Title"), Some(1));
assert_eq!(heading_level("Normal"), None);
assert_eq!(heading_level("BodyText"), None);
assert_eq!(heading_level("Heading0"), None);
assert_eq!(heading_level("Heading10"), None);
}
#[cfg(feature = "office")]
#[test]
fn test_next_ordered_number_increments_and_resets() {
let mut counters: Vec<usize> = Vec::new();
assert_eq!(next_ordered_number(&mut counters, 0), 1);
assert_eq!(next_ordered_number(&mut counters, 0), 2);
assert_eq!(next_ordered_number(&mut counters, 1), 1);
assert_eq!(next_ordered_number(&mut counters, 1), 2);
assert_eq!(next_ordered_number(&mut counters, 0), 3);
assert_eq!(next_ordered_number(&mut counters, 1), 1);
}
#[cfg(feature = "office")]
#[test]
fn test_list_kind_bullet_vs_ordered() {
use docx_rs::*;
let numberings = Numberings::new()
.add_abstract_numbering(AbstractNumbering::new(10).add_level(Level::new(
0,
Start::new(1),
NumberFormat::new("decimal"),
LevelText::new("%1."),
LevelJc::new("left"),
)))
.add_abstract_numbering(AbstractNumbering::new(11).add_level(Level::new(
0,
Start::new(1),
NumberFormat::new("bullet"),
LevelText::new("\u{2022}"),
LevelJc::new("left"),
)))
.add_numbering(Numbering::new(1, 10))
.add_numbering(Numbering::new(2, 11));
assert_eq!(list_kind(&numberings, 1, 0), ListKind::Ordered);
assert_eq!(list_kind(&numberings, 2, 0), ListKind::Bullet);
assert_eq!(list_kind(&numberings, 99, 0), ListKind::Bullet);
}
#[cfg(feature = "office")]
#[test]
fn test_table_to_markdown_renders_grid() {
use docx_rs::*;
let cell = |text: &str| {
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text(text)))
};
let table = Table::new(vec![
TableRow::new(vec![cell("Name"), cell("Role")]),
TableRow::new(vec![cell("Ada"), cell("Engineer")]),
]);
let md = table_to_markdown(&table);
let lines: Vec<&str> = md.lines().collect();
assert_eq!(lines[0], "| Name | Role |");
assert_eq!(lines[1], "| --- | --- |");
assert_eq!(lines[2], "| Ada | Engineer |");
}
#[cfg(feature = "office")]
#[test]
fn test_paragraph_plain_text_includes_hyperlink_text() {
use docx_rs::*;
let para = Paragraph::new()
.add_run(Run::new().add_text("See section "))
.add_hyperlink(
Hyperlink::new("_bookmark1", HyperlinkType::Anchor)
.add_run(Run::new().add_text("Background")),
)
.add_run(Run::new().add_text(" for details"));
assert_eq!(
paragraph_plain_text(¶),
"See section Background for details"
);
}
#[cfg(feature = "office")]
fn pack_docx(docx: docx_rs::Docx) -> Vec<u8> {
let mut cursor = std::io::Cursor::new(Vec::new());
docx.build().pack(&mut cursor).expect("pack docx");
cursor.into_inner()
}
#[cfg(feature = "office")]
#[test]
fn test_count_pages_counts_manual_breaks() {
use docx_rs::*;
let one_page = pack_docx(
Docx::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("only page"))),
);
assert_eq!(count_pages(&one_page), 1);
let two_pages = pack_docx(
Docx::new()
.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("page one"))
.add_run(Run::new().add_break(BreakType::Page)),
)
.add_paragraph(Paragraph::new().add_run(Run::new().add_text("page two"))),
);
assert_eq!(count_pages(&two_pages), 2);
}
#[cfg(feature = "office")]
#[tokio::test]
async fn test_convert_preserves_structure() {
use std::io::Write;
use docx_rs::*;
let docx = Docx::new()
.add_abstract_numbering(AbstractNumbering::new(10).add_level(Level::new(
0,
Start::new(1),
NumberFormat::new("decimal"),
LevelText::new("%1."),
LevelJc::new("left"),
)))
.add_abstract_numbering(AbstractNumbering::new(11).add_level(Level::new(
0,
Start::new(1),
NumberFormat::new("bullet"),
LevelText::new("\u{2022}"),
LevelJc::new("left"),
)))
.add_numbering(Numbering::new(1, 10))
.add_numbering(Numbering::new(2, 11))
.add_paragraph(
Paragraph::new()
.style("Heading1")
.add_run(Run::new().add_text("Title")),
)
.add_paragraph(Paragraph::new().add_run(Run::new().add_text("Intro paragraph.")))
.add_paragraph(
Paragraph::new()
.style("Heading2")
.add_run(Run::new().add_text("Section")),
)
.add_paragraph(
Paragraph::new()
.numbering(NumberingId::new(1), IndentLevel::new(0))
.add_run(Run::new().add_text("First")),
)
.add_paragraph(
Paragraph::new()
.numbering(NumberingId::new(1), IndentLevel::new(0))
.add_run(Run::new().add_text("Second")),
)
.add_paragraph(
Paragraph::new()
.numbering(NumberingId::new(2), IndentLevel::new(0))
.add_run(Run::new().add_text("Bullet A")),
)
.add_paragraph(
Paragraph::new()
.numbering(NumberingId::new(2), IndentLevel::new(0))
.add_run(Run::new().add_text("Bullet B")),
)
.add_table(Table::new(vec![
TableRow::new(vec![
TableCell::new()
.add_paragraph(Paragraph::new().add_run(Run::new().add_text("H1"))),
TableCell::new()
.add_paragraph(Paragraph::new().add_run(Run::new().add_text("H2"))),
]),
TableRow::new(vec![
TableCell::new()
.add_paragraph(Paragraph::new().add_run(Run::new().add_text("a"))),
TableCell::new()
.add_paragraph(Paragraph::new().add_run(Run::new().add_text("b"))),
]),
]));
let bytes = pack_docx(docx);
let mut tmp = tempfile::Builder::new().suffix(".docx").tempfile().unwrap();
tmp.write_all(&bytes).unwrap();
tmp.flush().unwrap();
let converter = DocxConverter::new();
let result = converter
.convert(
tmp.path(),
OutputFormat::Markdown {
split_pages: false,
optimize_for_llm: true,
},
ConversionOptions::default(),
)
.await
.unwrap();
let markdown = String::from_utf8(result.content[0].data.clone()).unwrap();
assert!(markdown.contains("# Title"), "missing H1:\n{markdown}");
assert!(markdown.contains("## Section"), "missing H2:\n{markdown}");
assert!(
markdown.contains("1. First"),
"missing ordered 1:\n{markdown}"
);
assert!(
markdown.contains("2. Second"),
"missing ordered 2:\n{markdown}"
);
assert!(
markdown.contains("- Bullet A"),
"missing bullet:\n{markdown}"
);
assert!(
markdown.contains("- Bullet B"),
"missing bullet:\n{markdown}"
);
assert!(
markdown.contains("| H1 | H2 |"),
"missing table header:\n{markdown}"
);
assert!(
markdown.contains("| a | b |"),
"missing table row:\n{markdown}"
);
assert_eq!(result.statistics.tables_extracted, 1);
}
#[cfg(feature = "office")]
#[tokio::test]
async fn test_deep_list_level_is_clamped() {
use std::io::Write;
use docx_rs::*;
let docx = Docx::new()
.add_abstract_numbering(AbstractNumbering::new(30).add_level(Level::new(
0,
Start::new(1),
NumberFormat::new("bullet"),
LevelText::new("\u{2022}"),
LevelJc::new("left"),
)))
.add_numbering(Numbering::new(1, 30))
.add_paragraph(Paragraph::new().add_run(Run::new().add_text("Intro.")))
.add_paragraph(
Paragraph::new()
.numbering(NumberingId::new(1), IndentLevel::new(100_000))
.add_run(Run::new().add_text("Deep item")),
);
let bytes = pack_docx(docx);
let mut tmp = tempfile::Builder::new().suffix(".docx").tempfile().unwrap();
tmp.write_all(&bytes).unwrap();
tmp.flush().unwrap();
let converter = DocxConverter::new();
let result = converter
.convert(
tmp.path(),
OutputFormat::Markdown {
split_pages: false,
optimize_for_llm: true,
},
ConversionOptions::default(),
)
.await
.unwrap();
let markdown = String::from_utf8(result.content[0].data.clone()).unwrap();
let line = markdown
.lines()
.find(|l| l.contains("Deep item"))
.expect("list item present");
let indent = line.len() - line.trim_start().len();
assert_eq!(
indent,
MAX_LIST_LEVEL * 2,
"list level was not clamped: {indent} leading spaces"
);
}
}