use super::{collapse_blank_lines, escape_cell, ooxml, squash};
use quick_xml::events::{BytesStart, Event};
const DOCUMENT: &str = "word/document.xml";
const MAX_TABLE_ROWS: usize = 200;
const MAX_TABLE_COLS: usize = 32;
pub(super) fn convert(bytes: &[u8]) -> Result<String, String> {
let mut zip = ooxml::open(bytes)?;
let xml = ooxml::read_xml(&mut zip, DOCUMENT)?
.ok_or_else(|| "docx has no word/document.xml; the file may be damaged".to_string())?;
let mut renderer = Renderer::default();
ooxml::walk(&xml, |event| {
renderer.event(event);
Ok(())
})?;
renderer.finish();
Ok(renderer.into_text())
}
#[derive(Default)]
struct Renderer {
blocks: Vec<String>,
para: String,
heading: Option<u8>,
list_item: bool,
in_table: bool,
rows: Vec<Vec<String>>,
row: Vec<String>,
cell: String,
}
impl Renderer {
fn event(&mut self, event: &Event<'_>) {
match event {
Event::Start(e) | Event::Empty(e) => self.start(e),
Event::End(e) => self.end(e.name().local_name().as_ref()),
Event::Text(_) | Event::CData(_) | Event::GeneralRef(_) => {
ooxml::push_text(event, self.sink())
}
_ => {}
}
}
fn sink(&mut self) -> &mut String {
if self.in_table {
&mut self.cell
} else {
&mut self.para
}
}
fn start(&mut self, e: &BytesStart<'_>) {
match e.name().local_name().as_ref() {
"p" => {
self.para.clear();
self.heading = None;
self.list_item = false;
}
"pStyle" => {
self.heading = ooxml::attr(e, "val").as_deref().and_then(heading_level);
}
"numPr" => self.list_item = true,
"br" | "cr" | "tab" => self.sink().push(' '),
"tbl" => {
self.flush_para();
self.in_table = true;
self.rows.clear();
}
"tr" => self.row.clear(),
"tc" => self.cell.clear(),
_ => {}
}
}
fn end(&mut self, name: &str) {
match name {
"p" => self.flush_para(),
"tc" => self.flush_cell(),
"tr" => self.flush_row(),
"tbl" => {
self.flush_row();
self.flush_table();
self.in_table = false;
}
_ => {}
}
}
fn flush_para(&mut self) {
let text = squash(&std::mem::take(&mut self.para));
let heading = self.heading.take();
let list_item = std::mem::take(&mut self.list_item);
if text.is_empty() {
return;
}
if self.in_table {
if !self.cell.is_empty() {
self.cell.push(' ');
}
self.cell.push_str(&text);
return;
}
match heading {
Some(level) => {
let block = format!("{} {text}", "#".repeat(level as usize));
self.push_block(block);
}
None if list_item => self.push_block(format!("- {text}")),
None => self.push_block(text),
}
}
fn flush_cell(&mut self) {
let cell = std::mem::take(&mut self.cell);
self.row.push(squash(&cell));
}
fn flush_row(&mut self) {
if !self.row.is_empty() {
self.rows.push(std::mem::take(&mut self.row));
}
}
fn flush_table(&mut self) {
let rows: Vec<Vec<String>> = std::mem::take(&mut self.rows)
.into_iter()
.filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
.collect();
if rows.is_empty() {
return;
}
let cols = rows
.iter()
.map(Vec::len)
.max()
.unwrap_or(0)
.min(MAX_TABLE_COLS);
if cols == 0 {
return;
}
let mut out = String::new();
for (index, row) in rows.iter().take(MAX_TABLE_ROWS).enumerate() {
out.push('|');
for column in 0..cols {
let cell = row.get(column).map(String::as_str).unwrap_or("");
out.push(' ');
out.push_str(&escape_cell(cell));
out.push_str(" |");
}
out.push('\n');
if index == 0 {
out.push('|');
for _ in 0..cols {
out.push_str(" --- |");
}
out.push('\n');
}
}
if rows.len() > MAX_TABLE_ROWS {
out.push_str(&format!(
"(table truncated: {} more rows omitted)\n",
rows.len() - MAX_TABLE_ROWS
));
}
self.push_block(out.trim_end().to_string());
}
fn push_block(&mut self, block: String) {
match self.blocks.last_mut() {
Some(prev) if prev.starts_with("- ") && block.starts_with("- ") => {
prev.push('\n');
prev.push_str(&block);
}
_ => self.blocks.push(block),
}
}
fn finish(&mut self) {
self.flush_para();
self.flush_row();
self.flush_table();
}
fn into_text(self) -> String {
collapse_blank_lines(&self.blocks.join("\n\n"))
}
}
fn heading_level(style: &str) -> Option<u8> {
if style == "Title" {
return Some(1);
}
if style == "Subtitle" {
return Some(2);
}
let digits = style.strip_prefix("Heading")?;
let level: u8 = digits.parse().ok()?;
Some(level.clamp(1, 6))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heading_styles_map_to_levels() {
assert_eq!(heading_level("Title"), Some(1));
assert_eq!(heading_level("Subtitle"), Some(2));
assert_eq!(heading_level("Heading1"), Some(1));
assert_eq!(heading_level("Heading3"), Some(3));
assert_eq!(heading_level("Heading9"), Some(6));
assert_eq!(heading_level("HeadingX"), None);
assert_eq!(heading_level("Quote"), None);
assert_eq!(heading_level("My Heading 2"), None);
}
}