use crate::Result;
use crate::core::config::ExtractionConfig;
use crate::core::mime::LEGACY_WORD_MIME_TYPE;
use crate::extraction::doc::{DocParagraph, extract_doc_text};
use crate::plugins::{InternalDocumentExtractor, Plugin};
use crate::types::Metadata;
use crate::types::internal::{ElementKind, InternalDocument, InternalElement};
use ahash::AHashMap;
use async_trait::async_trait;
use std::borrow::Cow;
#[cfg_attr(alef, alef(skip))]
pub struct DocExtractor;
impl DocExtractor {
pub(crate) fn new() -> Self {
Self
}
}
impl Default for DocExtractor {
fn default() -> Self {
Self::new()
}
}
impl Plugin for DocExtractor {
fn name(&self) -> &str {
"doc-extractor"
}
fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
fn description(&self) -> &str {
"Native DOC text extraction via OLE/CFB parsing"
}
fn author(&self) -> &str {
"Xberg Team"
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl InternalDocumentExtractor for DocExtractor {
async fn extract_content(
&self,
content: &[u8],
mime_type: &str,
config: &ExtractionConfig,
) -> Result<InternalDocument> {
let result = {
#[cfg(feature = "tokio-runtime")]
if crate::core::batch_mode::is_batch_mode() {
if config.cancel_token.as_ref().map(|t| t.is_cancelled()).unwrap_or(false) {
return Err(crate::error::XbergError::Cancelled);
}
let content_owned = content.to_vec();
let span = tracing::Span::current();
tokio::task::spawn_blocking(move || -> crate::error::Result<_> {
let _guard = span.entered();
extract_doc_text(&content_owned)
})
.await
.map_err(|e| crate::error::XbergError::parsing(format!("DOC extraction task failed: {e}")))?
} else {
extract_doc_text(content)
}
#[cfg(not(feature = "tokio-runtime"))]
{
if config.cancel_token.as_ref().map(|t| t.is_cancelled()).unwrap_or(false) {
return Err(crate::error::XbergError::Cancelled);
}
extract_doc_text(content)
}
}?;
let mut doc = InternalDocument::new("doc");
doc.mime_type = mime_type.to_string();
doc.processing_warnings.extend(result.processing_warnings);
doc.metadata = build_metadata(result.metadata);
if result.paragraphs.is_empty() {
push_blank_line_chunks(&mut doc, &result.content);
} else {
push_paragraph_elements(&mut doc, &result.paragraphs);
}
Ok(doc)
}
fn supported_mime_types(&self) -> &[&str] {
&[LEGACY_WORD_MIME_TYPE]
}
fn priority(&self) -> i32 {
60
}
}
fn build_metadata(source: crate::extraction::doc::DocMetadata) -> Metadata {
let mut additional = AHashMap::new();
if let Some(revision) = source.revision_number {
additional.insert(Cow::Borrowed("revision"), serde_json::Value::String(revision));
}
additional.insert(
Cow::Borrowed("extraction_method"),
serde_json::Value::String("native_ole".to_string()),
);
let (authors, created_by) = match source.author {
Some(author) => (Some(vec![author.clone()]), Some(author)),
None => (None, None),
};
Metadata {
title: source.title,
subject: source.subject,
authors,
created_by,
modified_by: source.last_author,
additional,
..Default::default()
}
}
fn looks_like_heading(text: &str, next: Option<&str>) -> bool {
let is_single_line = !text.contains('\n');
let is_short = text.len() <= 80;
let no_trailing_punct = !text.ends_with('.') && !text.ends_with(':') && !text.ends_with(';');
let next_is_longer = next.is_some_and(|next| !next.is_empty() && next.len() > text.len());
is_single_line && is_short && no_trailing_punct && next_is_longer
}
fn push_blank_line_chunks(doc: &mut InternalDocument, content: &str) {
let chunks: Vec<&str> = content.split("\n\n").collect();
for (i, chunk) in chunks.iter().enumerate() {
let trimmed = chunk.trim();
if trimmed.is_empty() {
continue;
}
let next = chunks.get(i + 1).map(|next| next.trim());
if looks_like_heading(trimmed, next.filter(|next| !next.is_empty())) {
doc.push_element(InternalElement::text(ElementKind::Heading { level: 2 }, trimmed, 0));
} else {
doc.push_element(InternalElement::text(ElementKind::Paragraph, trimmed, 0));
}
}
}
fn push_paragraph_elements(doc: &mut InternalDocument, paragraphs: &[DocParagraph]) {
let styled_headings = paragraphs
.iter()
.any(|paragraph| paragraph.heading_level.is_some() && paragraph.list.is_none());
let mut open: Vec<bool> = Vec::new();
for (i, paragraph) in paragraphs.iter().enumerate() {
let text = paragraph.content.trim();
if text.is_empty() {
continue;
}
let Some(list) = paragraph.list else {
close_lists(doc, &mut open, 0);
let kind = match heading_kind(paragraphs, i, text, styled_headings) {
Some(level) => ElementKind::Heading { level },
None => ElementKind::Paragraph,
};
doc.push_element(InternalElement::text(kind, text, 0));
continue;
};
let depth = usize::from(list.level) + 1;
close_lists(doc, &mut open, depth);
if open.len() == depth && open.last() != Some(&list.ordered) {
close_lists(doc, &mut open, depth - 1);
}
while open.len() < depth {
let element_depth = u16::try_from(open.len()).unwrap_or(u16::MAX);
doc.push_element(InternalElement::text(
ElementKind::ListStart { ordered: list.ordered },
"",
element_depth,
));
open.push(list.ordered);
}
let element_depth = u16::try_from(open.len()).unwrap_or(u16::MAX);
doc.push_element(InternalElement::text(
ElementKind::ListItem { ordered: list.ordered },
text,
element_depth,
));
}
close_lists(doc, &mut open, 0);
}
fn heading_kind(paragraphs: &[DocParagraph], index: usize, text: &str, styled_headings: bool) -> Option<u8> {
if styled_headings {
return paragraphs.get(index).and_then(|paragraph| paragraph.heading_level);
}
let next = paragraphs
.get(index + 1)
.map(|next| next.content.trim())
.filter(|next| !next.is_empty());
looks_like_heading(text, next).then_some(2)
}
fn close_lists(doc: &mut InternalDocument, open: &mut Vec<bool>, target: usize) {
while open.len() > target {
open.pop();
let element_depth = u16::try_from(open.len()).unwrap_or(u16::MAX);
doc.push_element(InternalElement::text(ElementKind::ListEnd, "", element_depth));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn corpus(relative: &str) -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
assert!(
path.exists(),
"corpus fixture missing at {}; fetch test_documents rather than skipping",
path.display()
);
std::fs::read(&path).expect("read fixture")
}
async fn internal_document(relative: &str) -> InternalDocument {
DocExtractor::new()
.extract_content(&corpus(relative), LEGACY_WORD_MIME_TYPE, &ExtractionConfig::default())
.await
.expect("DOC extraction should succeed")
}
#[tokio::test]
async fn list_containers_carry_the_kind_resolved_from_nfc() {
let doc = internal_document("../../test_documents/doc/unit_test_lists.doc").await;
let mut ordered = 0;
let mut bulleted = 0;
for element in &doc.elements {
if let ElementKind::ListStart { ordered: is_ordered } = element.kind {
if is_ordered { ordered += 1 } else { bulleted += 1 }
}
}
assert!(
ordered > 0 && bulleted > 0,
"the document mixes nfc 0 and nfc 23, so both container kinds must appear; \
got {ordered} ordered and {bulleted} bulleted -- a single kind means nfc was \
never read and a default was used"
);
}
#[tokio::test]
async fn list_containers_are_balanced() {
let doc = internal_document("../../test_documents/doc/unit_test_lists.doc").await;
let mut depth: i32 = 0;
for element in &doc.elements {
match element.kind {
ElementKind::ListStart { .. } => depth += 1,
ElementKind::ListEnd => {
depth -= 1;
assert!(depth >= 0, "a list was closed that had not been opened");
}
_ => {}
}
}
assert_eq!(depth, 0, "every opened list container must be closed");
}
#[tokio::test]
async fn a_document_that_applies_heading_styles_uses_them_and_their_levels() {
let doc = internal_document("../../test_documents/doc/unit_test_lists.doc").await;
let levels: Vec<u8> = doc
.elements
.iter()
.filter_map(|element| match element.kind {
ElementKind::Heading { level } => Some(level),
_ => None,
})
.collect();
assert_eq!(
levels.len(),
7,
"expected the 7 style-declared headings; got {levels:?}"
);
assert!(
levels.contains(&1) && levels.contains(&3),
"levels must come from the styles actually applied (heading 1 and heading 3); got {levels:?}"
);
assert!(
!levels.contains(&2),
"h2 is what the shape heuristic emits for everything; seeing it here means the \
heuristic ran instead of the styles: {levels:?}"
);
}
#[tokio::test]
async fn a_document_whose_only_styled_heading_is_list_bound_falls_back_to_shape() {
let doc = internal_document("../../test_documents/vendored/unstructured/doc/simple.doc").await;
let headings = doc
.elements
.iter()
.filter(|element| matches!(element.kind, ElementKind::Heading { .. }))
.count();
assert_eq!(
headings, 3,
"expected the shape heuristic's 3 headings; 0 means the document was treated as \
style-declaring on the strength of a paragraph emitted as a ListItem"
);
}
#[tokio::test]
async fn a_list_free_document_emits_no_list_elements() {
let doc = internal_document("../../test_documents/vendored/unstructured/doc/fake.doc").await;
assert!(
!doc.elements.iter().any(|element| matches!(
element.kind,
ElementKind::ListStart { .. } | ElementKind::ListEnd | ElementKind::ListItem { .. }
)),
"a document with no list bindings must not gain list structure"
);
}
#[tokio::test]
async fn test_doc_extractor_plugin_interface() {
let extractor = DocExtractor::new();
assert_eq!(extractor.name(), "doc-extractor");
assert_eq!(extractor.version(), env!("CARGO_PKG_VERSION"));
assert_eq!(extractor.priority(), 60);
assert_eq!(extractor.supported_mime_types(), &["application/msword"]);
}
#[tokio::test]
async fn test_doc_extractor_initialize_shutdown() {
let extractor = DocExtractor::new();
assert!(extractor.initialize().is_ok());
assert!(extractor.shutdown().is_ok());
}
#[tokio::test]
async fn test_doc_extractor_real_file() {
let test_file = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test_documents/vendored/unstructured/doc/simple.doc");
if !test_file.exists() {
return;
}
let content = std::fs::read(&test_file).expect("Failed to read test DOC");
let extractor = DocExtractor::new();
let config = ExtractionConfig::default();
let result = extractor
.extract_content(&content, "application/msword", &config)
.await
.expect("DOC extraction failed");
let result =
crate::extraction::derive::derive_extraction_result(result, true, crate::core::config::OutputFormat::Plain);
assert!(!result.content.is_empty(), "Should extract text from DOC");
assert_eq!(&*result.mime_type, "application/msword");
}
#[tokio::test]
async fn test_doc_document_structure_with_heuristic_headings() {
let test_file = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test_documents/vendored/unstructured/doc/simple.doc");
if !test_file.exists() {
return;
}
let content = std::fs::read(&test_file).expect("Failed to read test DOC");
let extractor = DocExtractor::new();
let config = ExtractionConfig {
include_document_structure: true,
..Default::default()
};
let result = extractor
.extract_content(&content, "application/msword", &config)
.await
.expect("DOC extraction failed");
let result =
crate::extraction::derive::derive_extraction_result(result, true, crate::core::config::OutputFormat::Plain);
assert!(result.document.is_some(), "Should produce document structure for DOC");
let doc = result.document.unwrap();
assert!(!doc.nodes.is_empty(), "Document structure should have nodes");
}
#[tokio::test]
async fn should_drop_hyperlink_instruction_but_keep_result_text_when_extracting_real_doc() {
let test_file = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test_documents/vendored/unstructured/doc/fake-doc-emphasized-text.doc");
if !test_file.exists() {
return;
}
let content = std::fs::read(&test_file).expect("Failed to read test DOC");
let extractor = DocExtractor::new();
let config = ExtractionConfig::default();
let result = extractor
.extract_content(&content, "application/msword", &config)
.await
.expect("DOC extraction failed");
let result =
crate::extraction::derive::derive_extraction_result(result, true, crate::core::config::OutputFormat::Plain);
assert!(
result.content.contains("A Link example"),
"field result text must be kept: {:?}",
result.content
);
assert!(
!result.content.contains("HYPERLINK"),
"field instruction keyword must be dropped: {:?}",
result.content
);
assert!(
!result.content.contains("http://github.com/"),
"field instruction URL must be dropped: {:?}",
result.content
);
}
#[tokio::test]
async fn test_doc_paragraph_mapping() {
let test_file =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test_documents/doc/unit_test_lists.doc");
if !test_file.exists() {
return;
}
let content = std::fs::read(&test_file).expect("Failed to read test DOC");
let extractor = DocExtractor::new();
let config = ExtractionConfig {
include_document_structure: true,
..Default::default()
};
let result = extractor
.extract_content(&content, "application/msword", &config)
.await
.expect("DOC extraction failed");
let result =
crate::extraction::derive::derive_extraction_result(result, true, crate::core::config::OutputFormat::Plain);
assert!(result.document.is_some(), "Should produce document structure");
let doc = result.document.unwrap();
let has_paragraph = doc.nodes.iter().any(|n| {
matches!(
n.content,
crate::types::document_structure::NodeContent::Paragraph { .. }
)
});
assert!(has_paragraph, "DOC should produce Paragraph nodes");
}
}