xberg 1.0.6

High-performance document intelligence library for Rust. Extract text, metadata, and structured data from PDFs, Office documents, images, and 98+ formats and 306 programming languages via tree-sitter code intelligence with async/sync APIs.
Documentation
//! Embedded object extraction from OOXML (DOCX/PPTX) archives.
//!
//! OOXML files are ZIP archives that may contain embedded objects in:
//! - DOCX: `word/embeddings/` directory
//! - PPTX: `ppt/embeddings/` directory
//!
//! This module extracts those embedded files, detects their MIME type,
//! and recursively processes them through the extraction pipeline.

use crate::core::config::ExtractionConfig;
use crate::types::{ArchiveEntry, ProcessingWarning};
use std::borrow::Cow;
use std::io::{Cursor, Read};

/// Extract embedded objects from an OOXML ZIP archive and recursively process them.
///
/// Scans the given `embeddings_prefix` directory (e.g. `word/embeddings/` or
/// `ppt/embeddings/`) inside the ZIP archive for embedded files. Known formats
/// (.xlsx, .pdf, .docx, .pptx, etc.) are recursively extracted. OLE compound
/// files (oleObject*.bin) are skipped with a warning unless their format can be
/// identified.
///
/// Returns `(children, warnings)` suitable for attaching to `InternalDocument`.
pub(crate) async fn extract_ooxml_embedded_objects(
    zip_bytes: &[u8],
    embeddings_prefix: &str,
    source_label: &str,
    config: &ExtractionConfig,
) -> (Vec<ArchiveEntry>, Vec<ProcessingWarning>) {
    let mut children = Vec::new();
    let mut warnings = Vec::new();

    if config.max_archive_depth == 0 {
        return (children, warnings);
    }

    let cursor = Cursor::new(zip_bytes);
    let mut archive = match zip::ZipArchive::new(cursor) {
        Ok(a) => a,
        Err(_) => return (children, warnings),
    };

    let embedding_names: Vec<String> = (0..archive.len())
        .filter_map(|i| {
            let file = archive.by_index(i).ok()?;
            let name = file.name().to_string();
            if name.starts_with(embeddings_prefix) && name.len() > embeddings_prefix.len() {
                Some(name)
            } else {
                None
            }
        })
        .collect();

    if embedding_names.is_empty() {
        return (children, warnings);
    }

    let mut child_config = config.clone();
    child_config.max_archive_depth = config.max_archive_depth.saturating_sub(1);

    for entry_name in &embedding_names {
        let filename = entry_name
            .strip_prefix(embeddings_prefix)
            .unwrap_or(entry_name)
            .to_string();

        let data = match archive.by_name(entry_name) {
            Ok(mut file) => {
                let mut buf = Vec::with_capacity(file.size() as usize);
                if file.read_to_end(&mut buf).is_err() {
                    warnings.push(ProcessingWarning {
                        source: Cow::Owned(format!("{}_embedded_objects", source_label)),
                        message: Cow::Owned(format!("Failed to read embedded file '{}'", filename)),
                    });
                    continue;
                }
                buf
            }
            Err(_) => continue,
        };

        if data.is_empty() {
            continue;
        }

        if config
            .max_embedded_file_bytes
            .is_some_and(|cap| data.len() as u64 > cap)
        {
            let cap = config.max_embedded_file_bytes.unwrap_or(0);
            warnings.push(ProcessingWarning {
                source: Cow::Owned(format!("{}_embedded_objects", source_label)),
                message: Cow::Owned(format!(
                    "Skipped embedded file '{}': size {} bytes exceeds cap {} bytes",
                    filename,
                    data.len(),
                    cap
                )),
            });
            continue;
        }

        let is_ole_binary = data.len() >= 4 && data[0..4] == [0xD0, 0xCF, 0x11, 0xE0];
        if is_ole_binary {
            warnings.push(ProcessingWarning {
                source: Cow::Owned(format!("{}_embedded_objects", source_label)),
                message: Cow::Owned(format!(
                    "Skipped OLE compound file '{}': format identification not supported",
                    filename
                )),
            });
            continue;
        }

        let detected_mime = crate::core::mime::detect_mime_type_from_bytes(&data).ok().or_else(|| {
            std::path::Path::new(&filename)
                .extension()
                .and_then(|ext| ext.to_str())
                .and_then(|ext| mime_guess::from_ext(ext).first())
                .map(|m| m.to_string())
        });

        let file_mime = match detected_mime {
            Some(m) if m != "application/octet-stream" => m,
            _ => {
                continue;
            }
        };

        match crate::core::extractor::extract_bytes(&data, &file_mime, &child_config).await {
            Ok(result) => {
                children.push(ArchiveEntry {
                    path: filename,
                    mime_type: file_mime,
                    result: Box::new(result),
                });
            }
            Err(e) => {
                warnings.push(ProcessingWarning {
                    source: Cow::Owned(format!("{}_embedded_objects", source_label)),
                    message: Cow::Owned(format!("Failed to extract embedded '{}': {}", filename, e)),
                });
            }
        }
    }

    (children, warnings)
}

#[cfg(all(test, feature = "office"))]
mod tests {
    use super::*;
    use std::io::Write;

    /// Build a minimal ZIP in memory with one file at the given path and contents.
    fn make_zip_with_file(entry_path: &str, entry_data: &[u8]) -> Vec<u8> {
        let buf = Cursor::new(Vec::new());
        let mut zip = zip::ZipWriter::new(buf);
        let options = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
        zip.start_file(entry_path, options).unwrap();
        zip.write_all(entry_data).unwrap();
        zip.finish().unwrap().into_inner()
    }

    #[tokio::test]
    async fn test_embedded_file_over_cap_skipped_with_warning() {
        let data = b"Hello world! This is a test document.";
        let zip_bytes = make_zip_with_file("word/embeddings/doc.txt", data);

        let config = ExtractionConfig {
            max_embedded_file_bytes: Some(10),
            ..Default::default()
        };

        let (children, warnings) =
            extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;

        assert!(
            children.is_empty(),
            "oversized embedded file must not produce a child entry"
        );
        assert_eq!(warnings.len(), 1, "exactly one warning expected");
        assert!(
            warnings[0].message.contains("exceeds cap"),
            "warning must mention cap: {}",
            warnings[0].message
        );
        assert!(
            warnings[0].message.contains("doc.txt"),
            "warning must name the file: {}",
            warnings[0].message
        );
    }

    #[tokio::test]
    async fn test_embedded_file_under_cap_proceeds_to_extraction() {
        let data = b"Hello";
        let zip_bytes = make_zip_with_file("word/embeddings/note.txt", data);

        let config = ExtractionConfig {
            max_embedded_file_bytes: Some(1024 * 1024),
            ..Default::default()
        };

        let (_children, warnings) =
            extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;

        let cap_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("exceeds cap")).collect();
        assert!(cap_warnings.is_empty(), "no size-cap warning expected for small file");
    }

    #[tokio::test]
    async fn test_embedded_file_no_cap_proceeds() {
        let data = b"some content";
        let zip_bytes = make_zip_with_file("word/embeddings/file.txt", data);

        let config = ExtractionConfig {
            max_embedded_file_bytes: None,
            ..Default::default()
        };

        let (_children, warnings) =
            extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;

        let cap_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("exceeds cap")).collect();
        assert!(cap_warnings.is_empty(), "no size-cap warning when cap is None");
    }
}