use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::Cursor;
use zip::ZipArchive;
use super::common::{docx_heading_level, image_placeholder, parse_docx_blocks, DocxBlock, DocxBlockKind};
use super::docx_aux::{
count_prefixed_entries, extract_notes_map, extract_text_from_xml, read_first_prefixed_entry,
read_zip_entry,
};
pub(super) const MAX_DOCX_AUX_XML_BYTES: u64 = 10 * 1024 * 1024;
pub(super) const LONG_PARAGRAPH_THRESHOLD: usize = 500;
pub(super) const SHORT_PARAGRAPH_THRESHOLD: usize = 80;
pub(super) const MAX_SECTION_CHARS: usize = 1200;
fn is_complete_sentence(text: &str) -> bool {
const SENTENCE_END: [char; 8] = ['.', '!', '?', '\u{3002}', '\u{ff01}', '\u{ff1f}', '\u{61f}', '\u{5c3}'];
let t = text.trim_end_matches(['"', '\'', ')', ']', '\u{201d}', '\u{2019}', ' ']);
t.chars().count() >= 12 && t.ends_with(SENTENCE_END)
}
pub(super) const SEMANTIC_SPLIT_MAX_BYTES: usize = 900;
pub(super) const SHORT_AGGREGATE_CHUNK_SIZE: usize = 700;
pub(super) const SHORT_AGGREGATE_CHUNK_OVERLAP: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ContentType {
PlainParagraph,
HeadingSection,
BulletNumberedList,
Table,
MixedContent,
CodeBlock,
FootnoteCaption,
Image,
LongSingleParagraph,
ShortDisconnectedParagraph,
HeaderFooter,
}
#[derive(Debug, Clone)]
pub(super) struct DocumentElement {
pub(super) content_type: ContentType,
pub(super) text: String,
pub(super) page_number: Option<usize>,
pub(super) heading_level: Option<u32>,
pub(super) footnote_refs: Vec<String>,
pub(super) endnote_refs: Vec<String>,
pub(super) image_rid: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct DocParseResult {
pub(super) elements: Vec<DocumentElement>,
pub(super) doc_metadata: Value,
pub(super) footnote_map: HashMap<String, String>,
pub(super) endnote_map: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub(super) struct ChunkRecordInput {
pub(super) content_type: ContentType,
pub(super) content: String,
pub(super) metadata: Value,
}
impl ContentType {
pub(super) fn as_str(self) -> &'static str {
match self {
ContentType::PlainParagraph => "plain_paragraph",
ContentType::HeadingSection => "heading",
ContentType::BulletNumberedList => "bullet_list",
ContentType::Table => "table",
ContentType::MixedContent => "mixed_content",
ContentType::CodeBlock => "code_block",
ContentType::FootnoteCaption => "footnote_caption",
ContentType::Image => "image",
ContentType::LongSingleParagraph => "long_single_paragraph",
ContentType::ShortDisconnectedParagraph => "short_disconnected_paragraph",
ContentType::HeaderFooter => "header_footer",
}
}
}
pub(super) fn parse_docx_document(bytes: &[u8]) -> Result<DocParseResult, String> {
let raw_blocks = parse_docx_blocks(bytes)?;
let mut elements = lower_blocks_to_elements(raw_blocks);
let cursor = Cursor::new(bytes);
let mut archive =
ZipArchive::new(cursor).map_err(|e| format!("DOCX is not a valid zip archive: {e}"))?;
let footnotes_xml = read_zip_entry(&mut archive, "word/footnotes.xml", MAX_DOCX_AUX_XML_BYTES)?;
let endnotes_xml = read_zip_entry(&mut archive, "word/endnotes.xml", MAX_DOCX_AUX_XML_BYTES)?;
let header_xml =
read_first_prefixed_entry(&mut archive, "word/header", MAX_DOCX_AUX_XML_BYTES)?;
let footer_xml =
read_first_prefixed_entry(&mut archive, "word/footer", MAX_DOCX_AUX_XML_BYTES)?;
let image_count = count_prefixed_entries(&mut archive, "word/media/")?;
let footnote_map = footnotes_xml
.as_deref()
.map(|x| extract_notes_map(x, "footnote"))
.unwrap_or_default();
let endnote_map = endnotes_xml
.as_deref()
.map(|x| extract_notes_map(x, "endnote"))
.unwrap_or_default();
if let Some(header_text) = header_xml
.as_ref()
.and_then(|x| extract_text_from_xml(x).ok())
.filter(|x| !x.trim().is_empty())
{
elements.push(DocumentElement {
content_type: ContentType::HeaderFooter,
text: header_text,
page_number: None,
heading_level: None,
footnote_refs: Vec::new(),
endnote_refs: Vec::new(),
image_rid: None,
});
}
if let Some(footer_text) = footer_xml
.as_ref()
.and_then(|x| extract_text_from_xml(x).ok())
.filter(|x| !x.trim().is_empty())
{
elements.push(DocumentElement {
content_type: ContentType::HeaderFooter,
text: footer_text,
page_number: None,
heading_level: None,
footnote_refs: Vec::new(),
endnote_refs: Vec::new(),
image_rid: None,
});
}
let doc_metadata = json!({
"header_text": header_xml.and_then(|x| extract_text_from_xml(&x).ok()),
"footer_text": footer_xml.and_then(|x| extract_text_from_xml(&x).ok()),
"image_count": image_count,
});
Ok(DocParseResult {
elements,
doc_metadata,
footnote_map,
endnote_map,
})
}
fn lower_blocks_to_elements(raw: Vec<DocxBlock>) -> Vec<DocumentElement> {
let mut out: Vec<DocumentElement> = Vec::with_capacity(raw.len());
let mut current_page: usize = 1;
for block in raw {
let block_page = current_page;
let triggers_page_break =
block.page_break || block.section_break || block.rendered_page_break;
match block.kind {
DocxBlockKind::Table => {
let table_text = block.text.trim().to_string();
if !table_text.is_empty() {
out.push(DocumentElement {
content_type: ContentType::Table,
text: table_text,
page_number: Some(block_page),
heading_level: None,
footnote_refs: block.footnote_refs.clone(),
endnote_refs: block.endnote_refs.clone(),
image_rid: None,
});
}
for (rid, alt) in &block.images {
out.push(DocumentElement {
content_type: ContentType::Image,
text: image_placeholder(alt.as_deref()),
page_number: Some(block_page),
heading_level: None,
footnote_refs: Vec::new(),
endnote_refs: Vec::new(),
image_rid: Some(rid.clone()),
});
}
}
DocxBlockKind::Paragraph => {
let text = block.text.trim().to_string();
let has_text = !text.is_empty();
let heading_level =
docx_heading_level(block.heading_style.as_deref(), block.outline_level);
let content_type = classify_paragraph_content(
&text,
block.heading_style.as_deref(),
heading_level,
block.is_list,
block.has_drawing,
);
if has_text || matches!(content_type, ContentType::Image) {
let normalized = super::common::text_with_image_marker(
text,
block.has_drawing,
block.image_alt.as_deref(),
);
out.push(DocumentElement {
content_type,
text: normalized,
page_number: Some(block_page),
heading_level: if matches!(content_type, ContentType::HeadingSection) {
heading_level
} else {
None
},
footnote_refs: block.footnote_refs.clone(),
endnote_refs: block.endnote_refs.clone(),
image_rid: block.image_rid.clone(),
});
for (rid, alt) in block.images.iter().skip(1) {
out.push(DocumentElement {
content_type: ContentType::Image,
text: image_placeholder(
alt.as_deref().or(block.image_alt.as_deref()),
),
page_number: Some(block_page),
heading_level: None,
footnote_refs: Vec::new(),
endnote_refs: Vec::new(),
image_rid: Some(rid.clone()),
});
}
}
}
}
if triggers_page_break {
current_page += 1;
}
}
out
}
fn classify_paragraph_content(
text: &str,
style_val: Option<&str>,
heading_level: Option<u32>,
is_list: bool,
has_drawing: bool,
) -> ContentType {
let style_lc = style_val.map(|s| s.to_ascii_lowercase());
let is_caption = style_lc
.as_deref()
.map(|s| s.contains("caption"))
.unwrap_or(false);
let is_code = style_lc
.as_deref()
.map(|s| s.contains("code"))
.unwrap_or(false)
|| text.contains("```");
if heading_level.is_some() {
ContentType::HeadingSection
} else if is_caption {
ContentType::FootnoteCaption
} else if is_list {
ContentType::BulletNumberedList
} else if is_code {
ContentType::CodeBlock
} else if has_drawing {
ContentType::Image
} else if text.len() > LONG_PARAGRAPH_THRESHOLD {
ContentType::LongSingleParagraph
} else if text.len() < SHORT_PARAGRAPH_THRESHOLD && !is_complete_sentence(text) {
ContentType::ShortDisconnectedParagraph
} else {
ContentType::PlainParagraph
}
}