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 main_part = super::images_rels::resolve_main_part(&mut archive)
.unwrap_or_else(|| "word/document.xml".to_string());
let mut document_xml_file = archive
.by_name(&main_part)
.map_err(|_| format!("main document part '{main_part}' not found in DOCX"))?;
let blocks = parse_document_xml_blocks_streaming(&mut document_xml_file)?;
drop(document_xml_file);
Ok(resolve_alt_chunks(&mut archive, &main_part, blocks))
}
const MAX_ALT_CHUNK_BYTES: u64 = 10 * 1024 * 1024;
fn resolve_alt_chunks<R: std::io::Read + std::io::Seek>(
archive: &mut ZipArchive<R>,
main_part: &str,
blocks: Vec<DocxBlock>,
) -> Vec<DocxBlock> {
if !blocks.iter().any(|b| b.alt_chunk_rid.is_some()) {
return blocks;
}
let (dir, file) = match main_part.rsplit_once('/') {
Some((d, f)) => (d.to_string(), f.to_string()),
None => (String::new(), main_part.to_string()),
};
let rels_path = if dir.is_empty() {
format!("_rels/{file}.rels")
} else {
format!("{dir}/_rels/{file}.rels")
};
let rels = super::images_rels::parse_rels_targets(archive, &rels_path);
let mut out = Vec::with_capacity(blocks.len());
for block in blocks {
let Some(rid) = block.alt_chunk_rid.clone() else {
out.push(block);
continue;
};
let Some(target) = rels.get(&rid) else {
continue;
};
let path = if dir.is_empty() {
target.clone()
} else {
format!("{dir}/{}", target.trim_start_matches("./"))
};
let Some(bytes) = read_capped(archive, &path, MAX_ALT_CHUNK_BYTES) else {
continue;
};
for text in imported_text(&path, &bytes) {
let mut b = block.clone();
b.alt_chunk_rid = None;
b.text = text;
out.push(b);
}
}
out
}
fn read_capped<R: std::io::Read + std::io::Seek>(
archive: &mut ZipArchive<R>,
path: &str,
cap: u64,
) -> Option<Vec<u8>> {
use std::io::Read;
let f = archive.by_name(path).ok()?;
if f.size() > cap {
return None;
}
let mut buf = Vec::new();
f.take(cap).read_to_end(&mut buf).ok()?;
Some(buf)
}
fn imported_text(path: &str, bytes: &[u8]) -> Vec<String> {
let lower = path.to_ascii_lowercase();
let markdown = if lower.ends_with(".html")
|| lower.ends_with(".htm")
|| lower.ends_with(".xhtml")
|| lower.ends_with(".mht")
|| lower.ends_with(".mhtml")
{
crate::formats::html::to_markdown_from_bytes(bytes).ok()
} else if lower.ends_with(".rtf") || bytes.starts_with(b"{\\rtf") {
crate::formats::rtf::to_markdown_from_bytes(bytes).ok()
} else if bytes.starts_with(b"PK\x03\x04") {
parse_docx_blocks(bytes).ok().map(|bs| {
bs.into_iter()
.map(|b| b.text)
.filter(|t| !t.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
})
} else {
Some(crate::text_encoding::decode_text(bytes).0)
};
markdown
.unwrap_or_default()
.split("\n\n")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}