use crate::Result;
use crate::core::config::ExtractionConfig;
use crate::core::mime::LEGACY_WORD_MIME_TYPE;
use crate::extraction::doc::{DocParagraph, DocSubdocument, DocSubdocumentKind, extract_doc_text};
use crate::plugins::{InternalDocumentExtractor, Plugin};
use crate::types::Metadata;
use crate::types::document_structure::ContentLayer;
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() && result.subdocuments.is_empty() {
push_blank_line_chunks(&mut doc, &result.content);
} else {
push_paragraph_elements(&mut doc, &result.paragraphs);
push_subdocuments(&mut doc, &result.subdocuments, config.content_filter.as_ref());
}
if let Some(filter) = &config.content_filter {
doc.elements.retain(|element| match element.layer {
ContentLayer::Header => filter.include_headers,
ContentLayer::Footer => filter.include_footers,
ContentLayer::Footnote if element.kind == ElementKind::FootnoteDefinition => filter.include_footnotes,
_ => true,
});
}
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 push_subdocuments(
doc: &mut InternalDocument,
subdocuments: &[DocSubdocument],
content_filter: Option<&crate::core::config::ContentFilterConfig>,
) {
let mut footnotes = 0;
let mut comments = 0;
for subdocument in subdocuments {
if subdocument.kind == DocSubdocumentKind::HeaderFooter
&& content_filter.is_some_and(|filter| !filter.include_headers || !filter.include_footers)
{
continue;
}
let (kind, layer, anchor) = match subdocument.kind {
DocSubdocumentKind::Header | DocSubdocumentKind::HeaderFooter => {
(ElementKind::Paragraph, ContentLayer::Header, None)
}
DocSubdocumentKind::Footer => (ElementKind::Paragraph, ContentLayer::Footer, None),
DocSubdocumentKind::TextBox => (ElementKind::Paragraph, ContentLayer::Body, None),
DocSubdocumentKind::Footnote => {
footnotes += 1;
let anchor = format!("fn{footnotes}");
(ElementKind::FootnoteDefinition, ContentLayer::Footnote, Some(anchor))
}
DocSubdocumentKind::Comment => {
comments += 1;
let anchor = format!("cmt{comments}");
(ElementKind::CommentDefinition, ContentLayer::Footnote, Some(anchor))
}
};
let lines: Vec<&str> = subdocument
.text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
let texts = match anchor {
Some(_) => vec![lines.join(" ")],
None => lines.iter().map(|line| line.to_string()).collect(),
};
for text in texts {
let mut element = InternalElement::text(kind, text, 0);
element.layer = layer;
element.anchor = anchor.clone();
doc.push_element(element);
}
}
}
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::*;
use crate::extraction::doc::tests::{
PlcKind, SEPARATOR_STORIES, SyntheticDoc, build_synthetic_doc, header_doc_stories,
};
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");
}
const FOOTNOTES: [&str; 2] = ["\u{2}First footnote\r", "\u{2}Second footnote\r"];
const COMMENTS: [&str; 2] = ["\u{5}First comment\r", "\u{5}Second comment\r"];
async fn extract_synthetic(plcf_hdd: PlcKind, note_plcs: PlcKind) -> InternalDocument {
extract_synthetic_with_config(plcf_hdd, note_plcs, ExtractionConfig::default()).await
}
async fn extract_synthetic_with_config(
plcf_hdd: PlcKind,
note_plcs: PlcKind,
config: ExtractionConfig,
) -> InternalDocument {
let bytes = build_synthetic_doc(&SyntheticDoc {
body: "Body paragraph\r",
footnotes: &FOOTNOTES,
header_stories: &header_doc_stories(),
comments: &COMMENTS,
plcf_hdd,
note_plcs,
});
DocExtractor::new()
.extract_content(&bytes, LEGACY_WORD_MIME_TYPE, &config)
.await
.expect("a synthetic DOC should extract without error")
}
fn on_layer(doc: &InternalDocument, layer: ContentLayer, needle: &str) -> bool {
doc.elements.iter().any(|e| e.layer == layer && e.text.contains(needle))
}
fn definitions(doc: &InternalDocument, kind: ElementKind) -> Vec<(Option<&str>, &str)> {
doc.elements
.iter()
.filter(|e| e.kind == kind)
.map(|e| (e.anchor.as_deref(), e.text.as_str()))
.collect()
}
fn warned(doc: &InternalDocument) -> bool {
doc.processing_warnings.iter().any(|w| w.source == "doc")
}
#[tokio::test]
async fn separator_stories_are_never_emitted() {
let doc = extract_synthetic(PlcKind::Valid, PlcKind::Valid).await;
assert!(on_layer(&doc, ContentLayer::Header, "Odd page header"));
for separator in SEPARATOR_STORIES {
let marker = separator.trim_end();
assert!(
!doc.elements.iter().any(|e| e.text.contains(marker)),
"separator story {marker:?} must not be emitted"
);
}
}
async fn assert_header_story_falls_back(plcf_hdd: PlcKind) {
let doc = extract_synthetic(plcf_hdd, PlcKind::Valid).await;
for needle in ["Odd page header", "Odd page footer"] {
assert!(on_layer(&doc, ContentLayer::Header, needle), "{needle:?} not on Header");
}
assert!(!doc.elements.iter().any(|e| e.layer == ContentLayer::Footer));
assert!(
warned(&doc),
"the fallback must be reported: {:?}",
doc.processing_warnings
);
}
#[tokio::test]
async fn a_missing_plcf_hdd_falls_back_to_the_whole_header_story() {
assert_header_story_falls_back(PlcKind::Absent).await;
}
#[tokio::test]
async fn a_plcf_hdd_past_the_table_stream_falls_back_to_the_whole_header_story() {
assert_header_story_falls_back(PlcKind::OutOfRange).await;
}
#[tokio::test]
async fn a_plcf_hdd_with_a_partial_section_falls_back_to_the_whole_header_story() {
assert_header_story_falls_back(PlcKind::PartialSection).await;
}
#[tokio::test]
async fn ambiguous_header_footer_text_requires_both_filter_permissions() {
for plcf_hdd in [PlcKind::Absent, PlcKind::OutOfRange, PlcKind::PartialSection] {
for (include_headers, include_footers, expected) in [
(false, false, false),
(true, false, false),
(false, true, false),
(true, true, true),
] {
let config = ExtractionConfig {
content_filter: Some(crate::core::config::ContentFilterConfig {
include_headers,
include_footers,
..Default::default()
}),
..Default::default()
};
let doc = extract_synthetic_with_config(plcf_hdd, PlcKind::Valid, config).await;
let ambiguous_text_present = doc.elements.iter().any(|element| {
element.text.contains("Odd page header") || element.text.contains("Odd page footer")
});
assert_eq!(
ambiguous_text_present, expected,
"ambiguous header/footer text with include_headers={include_headers} and include_footers={include_footers}"
);
}
}
}
#[tokio::test]
async fn include_footnotes_false_drops_footnotes_but_preserves_comments() {
let config = ExtractionConfig {
content_filter: Some(crate::core::config::ContentFilterConfig::default()),
..Default::default()
};
let doc = extract_synthetic_with_config(PlcKind::Valid, PlcKind::Valid, config).await;
assert_eq!(definitions(&doc, ElementKind::FootnoteDefinition), Vec::new());
assert_eq!(
definitions(&doc, ElementKind::CommentDefinition),
vec![(Some("cmt1"), "First comment"), (Some("cmt2"), "Second comment")]
);
}
#[tokio::test]
async fn a_note_table_that_omits_the_last_note_falls_back_to_one_definition_with_a_warning() {
let doc = extract_synthetic(PlcKind::Valid, PlcKind::MissingLastStory).await;
let footnotes = definitions(&doc, ElementKind::FootnoteDefinition);
assert_eq!(footnotes.len(), 1, "{footnotes:?}");
assert!(
footnotes[0].1.contains("Second footnote"),
"the uncovered note must not be dropped: {footnotes:?}"
);
assert!(
warned(&doc),
"the fallback must be reported: {:?}",
doc.processing_warnings
);
}
#[tokio::test]
async fn missing_note_tables_make_each_note_story_one_definition_with_a_warning() {
let doc = extract_synthetic(PlcKind::Valid, PlcKind::Absent).await;
let footnotes = definitions(&doc, ElementKind::FootnoteDefinition);
assert_eq!(footnotes.len(), 1, "{footnotes:?}");
assert_eq!(footnotes[0].0, Some("fn1"));
assert!(footnotes[0].1.contains("First footnote") && footnotes[0].1.contains("Second footnote"));
let comments = definitions(&doc, ElementKind::CommentDefinition);
assert_eq!(comments.len(), 1, "{comments:?}");
assert_eq!(comments[0].0, Some("cmt1"));
assert!(comments[0].1.contains("First comment") && comments[0].1.contains("Second comment"));
assert!(
warned(&doc),
"the fallback must be reported: {:?}",
doc.processing_warnings
);
}
}