use docparser_core::error::{DocError, Result};
use docparser_core::plugin::{ParseCapability, RawDoc, RawPage};
use quick_xml::events::Event;
use quick_xml::Reader;
use crate::zip_read_str;
pub struct DocxRawBackend;
impl ParseCapability for DocxRawBackend {
fn id(&self) -> &str {
"office-raw"
}
fn formats(&self) -> &[&str] {
&["docx"]
}
fn parse(&self, path: &str) -> Result<RawDoc> {
let file = std::fs::File::open(path)
.map_err(|e| DocError::corrupt(format!("cannot open {path}: {e}")))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|e| DocError::corrupt(format!("ZIP open failed: {e}")))?;
let xml = zip_read_str(&mut zip, "word/document.xml")?
.ok_or_else(|| DocError::corrupt("missing word/document.xml"))?;
let items = docx_raw_text(&xml)?;
let page = RawPage {
name: None,
items,
..Default::default()
};
Ok(RawDoc {
pages: vec![page],
..Default::default()
})
}
}
fn docx_raw_text(xml: &str) -> Result<Vec<docparser_core::plugin::RawTextItem>> {
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut items = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) if e.name().local_name().as_ref() == crate::tags::W_BODY => {
break
}
Ok(Event::Eof) => return Err(DocError::corrupt("no w:body found")),
Err(e) => return Err(DocError::corrupt(format!("XML parse error: {e}"))),
_ => {}
}
buf.clear();
}
buf.clear();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let name = e.name().local_name();
let name = name.as_ref();
if name == crate::tags::W_P {
let t = extract_para_text(&mut reader)?;
let trimmed = t.trim().to_string();
if !trimmed.is_empty() {
items.push(docparser_core::plugin::RawTextItem {
text: trimmed,
bbox: None,
});
}
} else if name == crate::tags::W_TBL {
let rows = extract_table_rows(&mut reader)?;
if !rows.is_empty() {
let t = rows
.iter()
.map(|r| r.join(","))
.collect::<Vec<_>>()
.join("\n");
items.push(docparser_core::plugin::RawTextItem {
text: t,
bbox: None,
});
}
} else if name == crate::tags::W_SDT {
extract_sdt_items(&mut reader, &mut items)?;
}
}
Ok(Event::End(ref e)) => {
if e.name().local_name().as_ref() == crate::tags::W_BODY {
break;
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(DocError::corrupt(format!("XML parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(items)
}
fn extract_sdt_items<R: std::io::BufRead>(
reader: &mut Reader<R>,
items: &mut Vec<docparser_core::plugin::RawTextItem>,
) -> Result<()> {
let mut buf = Vec::new();
let mut depth = 1;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
depth += 1;
if e.name().local_name().as_ref() == crate::tags::W_P {
let t = extract_para_text(reader)?;
let trimmed = t.trim().to_string();
if !trimmed.is_empty() {
items.push(docparser_core::plugin::RawTextItem {
text: trimmed,
bbox: None,
});
}
}
}
Ok(Event::End(ref _e)) => {
depth -= 1;
if depth == 0 {
break;
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(DocError::corrupt(format!("XML sdt parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(())
}
fn extract_para_text<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<String> {
let mut out = String::new();
let mut buf = Vec::new();
let mut depth = 1;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
depth += 1;
match e.name().local_name().as_ref() {
crate::tags::W_TAB => out.push('\t'),
crate::tags::W_BR | crate::tags::W_CR => out.push('\n'),
_ => {}
}
}
Ok(Event::End(ref _e)) => {
depth -= 1;
if depth == 0 {
break;
}
}
Ok(Event::Empty(ref e)) => match e.name().local_name().as_ref() {
crate::tags::W_TAB => out.push('\t'),
crate::tags::W_BR | crate::tags::W_CR => out.push('\n'),
_ => {}
},
Ok(Event::Text(ref e)) => {
let raw = e.xml10_content();
if !raw.trim().is_empty() {
if let Ok(t) = quick_xml::escape::unescape(&raw) {
out.push_str(&t);
}
}
}
Ok(Event::GeneralRef(ref e)) => crate::push_general_ref(&mut out, e),
Ok(Event::CData(ref e)) => {
out.push_str(e.as_ref());
}
Err(e) => return Err(DocError::corrupt(format!("XML para parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(out)
}
fn extract_table_rows<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<Vec<Vec<String>>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut current_row: Vec<String> = Vec::new();
let mut current_cell = String::new();
let mut tbl_depth = 1;
let mut buf = Vec::new();
let mut cell_has_content = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
tbl_depth += 1;
match e.name().local_name().as_ref() {
crate::tags::W_P => {
let t = extract_para_text(reader)?;
tbl_depth -= 1;
if !t.trim().is_empty() {
if cell_has_content {
current_cell.push('\n');
}
current_cell.push_str(t.trim());
cell_has_content = true;
}
continue;
}
crate::tags::W_TBL => {
let nested = extract_table_rows(reader)?;
tbl_depth -= 1;
if !nested.is_empty() {
if cell_has_content {
current_cell.push('\n');
}
current_cell.push_str(
&nested
.iter()
.map(|r| r.join(","))
.collect::<Vec<_>>()
.join("\n"),
);
cell_has_content = true;
}
continue;
}
_ => {}
}
}
Ok(Event::End(ref e)) => {
tbl_depth -= 1;
match e.name().local_name().as_ref() {
crate::tags::W_TBL => {
if tbl_depth == 0 {
if !current_row.is_empty() {
rows.push(std::mem::take(&mut current_row));
}
break;
}
}
crate::tags::W_TR => {
if !current_row.is_empty() {
rows.push(std::mem::take(&mut current_row));
}
}
crate::tags::W_TC => {
if cell_has_content {
current_row.push(current_cell.trim().to_string());
} else {
current_row.push(String::new());
}
current_cell.clear();
cell_has_content = false;
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(DocError::corrupt(format!("XML table parse error: {e}"))),
_ => {}
}
buf.clear();
}
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_para_simple() {
let xml = r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:p><w:r><w:t>Hello</w:t></w:r></w:p>
</w:body>"#;
let items = docx_raw_text(xml).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text, "Hello");
}
#[test]
fn test_para_tab_br() {
let xml = concat!(
r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
r#" <w:p><w:r><w:t>a</w:t></w:r><w:tab/><w:r><w:t>b</w:t></w:r><w:br/><w:r><w:t>c</w:t></w:r></w:p>"#,
r#"</w:body>"#
);
let items = docx_raw_text(xml).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text, "a\tb\nc");
}
#[test]
fn test_simple_table() {
let xml = concat!(
r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
r#" <w:tbl>"#,
r#" <w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>"#,
r#" </w:tbl>"#,
r#"</w:body>"#
);
let items = docx_raw_text(xml).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text, "A,B");
}
#[test]
fn test_nested_table() {
let xml = concat!(
r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
r#" <w:tbl>"#,
r#" <w:tr>"#,
r#" <w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>"#,
r#" <w:tc>"#,
r#" <w:p><w:r><w:t>Y</w:t></w:r></w:p>"#,
r#" <w:tbl>"#,
r#" <w:tr><w:tc><w:p><w:r><w:t>innerA</w:t></w:r></w:p></w:tc>"#,
r#" <w:tc><w:p><w:r><w:t>innerB</w:t></w:r></w:p></w:tc></w:tr>"#,
r#" </w:tbl>"#,
r#" </w:tc>"#,
r#" </w:tr>"#,
r#" </w:tbl>"#,
r#"</w:body>"#
);
let items = docx_raw_text(xml).unwrap();
assert_eq!(items.len(), 1);
assert!(items[0].text.contains("innerA"));
assert!(items[0].text.contains("innerB"));
}
#[test]
fn test_multi_para() {
let xml = concat!(
r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
r#" <w:p><w:r><w:t>Para1</w:t></w:r></w:p>"#,
r#" <w:p><w:r><w:t>Para2</w:t></w:r></w:p>"#,
r#"</w:body>"#
);
let items = docx_raw_text(xml).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].text, "Para1");
assert_eq!(items[1].text, "Para2");
}
#[test]
fn smoke_parse_minimal_docx_zip() {
use std::io::Write;
const DOCUMENT_XML: &str = concat!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
r#"<w:body>"#,
r#"<w:p><w:r><w:t>Smoke 文本</w:t></w:r></w:p>"#,
r#"<w:p><w:r><w:t>a & b</w:t></w:r></w:p>"#,
r#"</w:body></w:document>"#
);
let dir = std::env::temp_dir().join("docparser_office_docx_smoke");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("min.docx");
{
let f = std::fs::File::create(&path).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default();
zw.start_file("word/document.xml", opts).unwrap();
zw.write_all(DOCUMENT_XML.as_bytes()).unwrap();
zw.finish().unwrap();
}
let raw = DocxRawBackend.parse(path.to_str().unwrap()).unwrap();
std::fs::remove_file(&path).ok();
assert_eq!(raw.pages.len(), 1);
assert_eq!(raw.pages[0].items.len(), 2);
assert_eq!(raw.pages[0].items[0].text, "Smoke 文本");
assert_eq!(raw.pages[0].items[1].text, "a & b");
}
}