use std::io::Cursor;
use zip::ZipArchive;
use super::block_model::{
docx_heading_level, DocxBlock, DocxBlockKind, IndexedParagraph, PageBreakSignal,
ParagraphEvent, MIN_PARAGRAPH_CHARS,
};
use super::images_rels::image_placeholder;
use super::stream_walker::parse_document_xml_blocks_streaming;
use super::xml_text::collapse_whitespace;
pub(super) fn parse_docx_indexed_paragraphs(bytes: &[u8]) -> Result<Vec<IndexedParagraph>, String> {
let events = parse_docx_paragraph_events(bytes)?;
Ok(events
.into_iter()
.enumerate()
.map(|(index, ev)| IndexedParagraph {
index,
text: ev.text,
is_heading: ev.is_heading,
heading_level: ev.heading_level,
is_list: ev.is_list,
is_table: ev.is_table,
})
.collect())
}
pub(super) fn parse_docx_paragraph_events(bytes: &[u8]) -> Result<Vec<ParagraphEvent>, String> {
let blocks = parse_docx_blocks(bytes)?;
let mut events: Vec<ParagraphEvent> = Vec::with_capacity(blocks.len());
for block in blocks {
let heading_level = match block.kind {
DocxBlockKind::Paragraph => {
docx_heading_level(block.heading_style.as_deref(), block.outline_level)
}
DocxBlockKind::Table => None,
};
let is_heading = heading_level.is_some();
let is_list = matches!(block.kind, DocxBlockKind::Paragraph) && block.is_list;
let is_table = matches!(block.kind, DocxBlockKind::Table);
let (text, signal) = match block.kind {
DocxBlockKind::Paragraph => {
let collapsed = collapse_whitespace(&block.text);
let normalized = if !collapsed.is_empty() {
collapsed
} else if block.has_drawing {
image_placeholder(block.image_alt.as_deref())
} else {
String::new()
};
let signal = if block.page_break {
PageBreakSignal::Explicit
} else if block.section_break {
PageBreakSignal::Section
} else if block.rendered_page_break {
PageBreakSignal::Rendered
} else {
PageBreakSignal::None
};
(normalized, signal)
}
DocxBlockKind::Table => {
let collapsed = collapse_whitespace(&block.text).trim().to_string();
(collapsed, PageBreakSignal::None)
}
};
if text.len() >= MIN_PARAGRAPH_CHARS {
events.push(ParagraphEvent {
text,
signal,
is_heading,
heading_level,
is_list,
is_table,
});
} else if !matches!(signal, PageBreakSignal::None) {
if let Some(last) = events.last_mut() {
if matches!(last.signal, PageBreakSignal::None) {
last.signal = signal;
}
}
}
}
Ok(events)
}
#[derive(Debug, Clone)]
pub(super) enum ParaOrImage {
Para(ParagraphEvent),
Image {
rid: Option<String>,
alt: Option<String>,
signal: PageBreakSignal,
},
}
pub(super) fn parse_docx_paragraph_events_with_images(
bytes: &[u8],
) -> Result<Vec<ParaOrImage>, String> {
let blocks = parse_docx_blocks(bytes)?;
let mut items: Vec<ParaOrImage> = Vec::with_capacity(blocks.len());
for block in blocks {
let heading_level = match block.kind {
DocxBlockKind::Paragraph => {
docx_heading_level(block.heading_style.as_deref(), block.outline_level)
}
DocxBlockKind::Table => None,
};
let is_heading = heading_level.is_some();
let is_list = matches!(block.kind, DocxBlockKind::Paragraph) && block.is_list;
let signal = if block.page_break {
PageBreakSignal::Explicit
} else if block.section_break {
PageBreakSignal::Section
} else if block.rendered_page_break {
PageBreakSignal::Rendered
} else {
PageBreakSignal::None
};
match block.kind {
DocxBlockKind::Paragraph => {
let collapsed = collapse_whitespace(&block.text);
let normalized = if !collapsed.is_empty() {
collapsed
} else if block.has_drawing {
image_placeholder(block.image_alt.as_deref())
} else {
String::new()
};
if normalized.len() >= MIN_PARAGRAPH_CHARS {
items.push(ParaOrImage::Para(ParagraphEvent {
text: normalized,
signal,
is_heading,
heading_level,
is_list,
is_table: false,
}));
} else if !matches!(signal, PageBreakSignal::None) {
if let Some(ParaOrImage::Para(last)) = items.last_mut() {
if matches!(last.signal, PageBreakSignal::None) {
last.signal = signal;
}
}
}
if block.has_drawing {
if block.images.is_empty() {
items.push(ParaOrImage::Image {
rid: block.image_rid,
alt: block.image_alt,
signal,
});
} else {
for (rid, alt) in block.images {
items.push(ParaOrImage::Image {
rid: Some(rid),
alt: alt.or_else(|| block.image_alt.clone()),
signal,
});
}
}
}
}
DocxBlockKind::Table => {
let collapsed = collapse_whitespace(&block.text).trim().to_string();
if collapsed.len() >= MIN_PARAGRAPH_CHARS {
items.push(ParaOrImage::Para(ParagraphEvent {
text: collapsed,
signal: PageBreakSignal::None,
is_heading: false,
heading_level: None,
is_list: false,
is_table: true,
}));
}
for (rid, alt) in block.images {
items.push(ParaOrImage::Image {
rid: Some(rid),
alt,
signal: PageBreakSignal::None,
});
}
}
}
}
Ok(items)
}
pub(super) fn parse_docx_indexed_items_with_images(
bytes: &[u8],
) -> Result<Vec<ParaOrImage>, String> {
parse_docx_paragraph_events_with_images(bytes)
}
pub(super) fn parse_docx_blocks(bytes: &[u8]) -> Result<Vec<DocxBlock>, String> {
let cursor = Cursor::new(bytes);
let mut archive =
ZipArchive::new(cursor).map_err(|e| format!("DOCX is not a valid zip archive: {e}"))?;
let mut document_xml_file = archive
.by_name("word/document.xml")
.map_err(|_| "word/document.xml not found in DOCX".to_string())?;
parse_document_xml_blocks_streaming(&mut document_xml_file)
}