use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::io::{BufReader, Cursor, Read};
use quick_xml::events::Event;
use quick_xml::name::QName;
use quick_xml::Reader;
use zip::ZipArchive;
pub const WORD_OOXML_EXTS: &[&str] = &[".docx", ".docm", ".dotx", ".dotm"];
pub fn is_word_ooxml(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
WORD_OOXML_EXTS.iter().any(|ext| lower.ends_with(ext))
}
pub fn word_exts_display() -> String {
WORD_OOXML_EXTS.join(", ")
}
pub(super) fn qname_eq(name: QName<'_>, expected: &[u8]) -> bool {
let n = name.as_ref();
n == expected || n.rsplit(|b| *b == b':').next() == Some(expected)
}
pub(super) fn push_text(target: &mut String, piece: &str) {
let trimmed = piece.trim();
if trimmed.is_empty() {
return;
}
if !target.is_empty() {
target.push(' ');
}
target.push_str(trimmed);
}
pub(super) fn collapse_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_space = false;
for ch in text.chars() {
if ch.is_whitespace() {
if !in_space {
out.push(' ');
in_space = true;
}
} else {
out.push(ch);
in_space = false;
}
}
out.trim().to_string()
}
pub(super) const MIN_PARAGRAPH_CHARS: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PageBreakSignal {
Explicit,
Section,
Rendered,
None,
}
#[derive(Debug, Clone)]
pub(super) struct ParagraphEvent {
pub text: String,
pub signal: PageBreakSignal,
pub is_heading: bool,
pub heading_level: Option<u32>,
pub is_list: bool,
pub is_table: bool,
}
#[derive(Debug, Clone)]
pub(super) struct IndexedParagraph {
pub index: usize,
pub text: String,
pub is_heading: bool,
pub heading_level: Option<u32>,
pub is_list: bool,
pub is_table: bool,
}
pub(super) fn docx_heading_level(style: Option<&str>, outline: Option<u32>) -> Option<u32> {
if let Some(raw) = style {
let lower = raw.to_ascii_lowercase();
let prefixes: &[(&str, u32)] = &[
("heading", 1),
("titre", 1),
("überschrift", 1),
("title", 1),
];
for (prefix, default_level) in prefixes {
if let Some(rest) = lower.strip_prefix(prefix) {
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = digits.parse::<u32>() {
if n > 0 {
return Some(n);
}
}
return Some(*default_level);
}
}
}
outline.map(|n| n + 1)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum DocxBlockKind {
Paragraph,
Table,
}
#[derive(Debug, Clone)]
pub(super) struct DocxBlock {
pub kind: DocxBlockKind,
pub text: String,
pub has_drawing: bool,
pub is_list: bool,
pub list_level: u8,
pub heading_style: Option<String>,
pub outline_level: Option<u32>,
pub page_break: bool,
pub section_break: bool,
pub rendered_page_break: bool,
pub image_alt: Option<String>,
pub image_rid: Option<String>,
pub footnote_refs: Vec<String>,
pub endnote_refs: Vec<String>,
pub num_id: Option<u32>,
pub hyperlinks: Vec<(String, String)>,
}
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 {
items.push(ParaOrImage::Image {
rid: block.image_rid,
alt: block.image_alt,
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,
}));
}
}
}
}
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)
}
#[derive(Default)]
struct TableState {
rows: Vec<Vec<String>>,
current_row: Vec<String>,
current_cell: String,
in_cell: bool,
in_tr_pr: bool,
in_header_row: bool,
cell_span: usize,
header_row_flags: Vec<bool>,
vmerge_col_content: Vec<String>,
cur_cell_is_vmerge_continuation: bool,
}
fn escape_md_cell(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'|' => out.push_str("\\|"),
'\n' | '\r' => out.push(' '),
_ => out.push(ch),
}
}
out
}
fn render_table_markdown(state: &TableState) -> String {
if state.rows.is_empty() {
return String::new();
}
let max_cols = state.rows.iter().map(|r| r.len()).max().unwrap_or(0);
if max_cols == 0 {
return String::new();
}
let mut header_count = state.header_row_flags.iter().take_while(|f| **f).count();
if header_count == 0 && state.rows.len() > 1 {
header_count = 1;
}
let mut out = String::new();
for (i, row) in state.rows.iter().enumerate() {
out.push('|');
for col in 0..max_cols {
let cell = row.get(col).map(String::as_str).unwrap_or("");
out.push(' ');
out.push_str(&escape_md_cell(cell));
out.push_str(" |");
}
out.push('\n');
if i + 1 == header_count && header_count < state.rows.len() {
out.push('|');
for _ in 0..max_cols {
out.push_str(" --- |");
}
out.push('\n');
}
}
while out.ends_with('\n') {
out.pop();
}
out
}
fn render_table_inline(state: &TableState) -> String {
let mut rows: Vec<String> = Vec::with_capacity(state.rows.len());
for row in &state.rows {
let cells: Vec<String> = row.iter().map(|c| c.replace(['\n', '\r'], " ")).collect();
rows.push(cells.join(" | "));
}
rows.join("; ")
}
fn harvest_image_alt(e: &quick_xml::events::BytesStart<'_>) -> Option<String> {
let mut descr: Option<String> = None;
let mut title: Option<String> = None;
let mut name: Option<String> = None;
for attr in e.attributes().flatten() {
let value = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_string();
if value.is_empty() {
continue;
}
if qname_eq(attr.key, b"descr") {
descr.get_or_insert(value);
} else if qname_eq(attr.key, b"title") {
title.get_or_insert(value);
} else if qname_eq(attr.key, b"name") {
let lower = value.to_ascii_lowercase();
let looks_generic = lower.starts_with("picture ")
|| lower.starts_with("image ")
|| lower.starts_with("graphic ")
|| lower.starts_with("chart ");
if !looks_generic {
name.get_or_insert(value);
}
}
}
descr.or(title).or(name)
}
fn harvest_note_id(e: &quick_xml::events::BytesStart<'_>) -> Option<String> {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"id") {
let value = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_string();
if !value.is_empty() {
return Some(value);
}
}
}
None
}
fn harvest_blip_embed(e: &quick_xml::events::BytesStart<'_>) -> Option<String> {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"embed") {
let value = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_string();
if !value.is_empty() {
return Some(value);
}
}
}
None
}
pub(super) fn image_placeholder(alt: Option<&str>) -> String {
match alt.map(str::trim).filter(|s| !s.is_empty()) {
Some(a) => format!("[Image: {a}]"),
None => "[Image]".to_string(),
}
}
pub(super) fn parse_rels_xml_images(xml: &str) -> HashMap<String, String> {
let mut images = HashMap::new();
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => {
let ename = e.name();
let ebytes = ename.as_ref();
let local: &[u8] = ebytes.rsplit(|b| *b == b':').next().unwrap_or(ebytes);
if local == b"Relationship" {
let mut id = String::new();
let mut target = String::new();
let mut rel_type = String::new();
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
let val = String::from_utf8_lossy(attr.value.as_ref()).to_string();
match key.as_str() {
"Id" => id = val,
"Target" => target = val,
"Type" => rel_type = val,
_ => {}
}
}
if rel_type.ends_with("/image") && !id.is_empty() && !target.is_empty() {
let normalized = if let Some(stripped) = target.strip_prefix("../") {
format!("word/{stripped}")
} else if target.starts_with("word/") {
target
} else {
format!("word/{target}")
};
images.insert(id, normalized);
}
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
images
}
pub(super) fn image_hash_name(bytes: &[u8], zip_path: &str) -> Option<String> {
let path = zip_path.to_ascii_lowercase();
let ext = if path.ends_with(".png") {
".png"
} else if path.ends_with(".jpg") {
".jpg"
} else if path.ends_with(".jpeg") {
".jpeg"
} else if path.ends_with(".gif") {
".gif"
} else if path.ends_with(".webp") {
".webp"
} else {
return None;
};
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
Some(format!("{:016x}{ext}", hasher.finish()))
}
fn is_list_style(style: &str) -> bool {
let lower = style.to_ascii_lowercase();
let prefixes = ["listbullet", "listnumber", "listparagraph", "list"];
for prefix in &prefixes {
if lower == *prefix || lower.starts_with(prefix) {
return true;
}
}
false
}
fn parse_document_xml_blocks_streaming<R: Read>(reader_src: R) -> Result<Vec<DocxBlock>, String> {
let mut reader = Reader::from_reader(BufReader::new(reader_src));
let mut buf = Vec::new();
let mut blocks: Vec<DocxBlock> = Vec::new();
let mut in_text = false;
let mut in_paragraph = false;
let mut para_text = String::new();
let mut para_sub_texts: Vec<String> = Vec::new();
let mut para_is_list = false;
let mut para_list_level: u8 = 0;
let mut para_has_drawing = false;
let mut para_has_page_break = false;
let mut para_has_section_break = false;
let mut para_has_rendered_break = false;
let mut para_style: Option<String> = None;
let mut para_outline_lvl: Option<u32> = None;
let mut para_image_alt: Option<String> = None;
let mut para_image_rid: Option<String> = None;
let mut para_footnote_refs: Vec<String> = Vec::new();
let mut para_endnote_refs: Vec<String> = Vec::new();
let mut para_num_id: Option<u32> = None;
let mut in_hyperlink = false;
let mut hyperlink_rid = String::new();
let mut hyperlink_text = String::new();
let mut para_hyperlinks: Vec<(String, String)> = Vec::new();
let mut drawing_depth: u32 = 0;
let mut in_run = false;
let mut in_rpr = false;
let mut cur_bold = false;
let mut cur_italic = false;
let mut cur_run_text = String::new();
let mut table_stack: Vec<TableState> = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name = e.name();
if qname_eq(name, b"tbl") {
table_stack.push(TableState::default());
} else if qname_eq(name, b"tr") {
if let Some(top) = table_stack.last_mut() {
top.current_row.clear();
top.in_header_row = false;
top.in_tr_pr = false;
}
} else if qname_eq(name, b"trPr") {
if let Some(top) = table_stack.last_mut() {
top.in_tr_pr = true;
}
} else if qname_eq(name, b"tblHeader") {
if let Some(top) = table_stack.last_mut() {
if top.in_tr_pr {
top.in_header_row = true;
}
}
} else if qname_eq(name, b"tc") {
if let Some(top) = table_stack.last_mut() {
top.in_cell = true;
top.current_cell.clear();
top.cell_span = 1;
top.cur_cell_is_vmerge_continuation = false;
}
} else if qname_eq(name, b"gridSpan") {
if let Some(top) = table_stack.last_mut() {
if top.in_cell {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw =
String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<usize>() {
if v > 1 {
top.cell_span = v;
}
}
break;
}
}
}
}
} else if qname_eq(name, b"vMerge") {
if let Some(top) = table_stack.last_mut() {
if top.in_cell {
let mut is_restart = false;
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let v = String::from_utf8_lossy(attr.value.as_ref());
is_restart = v.trim() == "restart";
break;
}
}
top.cur_cell_is_vmerge_continuation = !is_restart;
}
}
} else if qname_eq(name, b"p") {
if table_stack.is_empty() {
in_paragraph = true;
para_text.clear();
para_sub_texts.clear();
para_is_list = false;
para_list_level = 0;
para_has_drawing = false;
para_has_page_break = false;
para_has_section_break = false;
para_has_rendered_break = false;
para_style = None;
para_outline_lvl = None;
para_image_alt = None;
para_image_rid = None;
para_footnote_refs.clear();
para_endnote_refs.clear();
para_num_id = None;
in_hyperlink = false;
hyperlink_rid.clear();
hyperlink_text.clear();
para_hyperlinks.clear();
drawing_depth = 0;
in_run = false;
in_rpr = false;
cur_bold = false;
cur_italic = false;
cur_run_text.clear();
}
} else if qname_eq(name, b"numPr") && in_paragraph {
para_is_list = true;
} else if qname_eq(name, b"br") && in_paragraph {
let mut is_page = false;
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"type") {
let value = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_ascii_lowercase();
if value == "page" {
para_has_page_break = true;
is_page = true;
}
break;
}
}
if !is_page {
let flushed = std::mem::take(&mut para_text).trim().to_string();
if !flushed.is_empty() {
para_sub_texts.push(flushed);
}
}
} else if qname_eq(name, b"sectPr") && in_paragraph {
para_has_section_break = true;
} else if qname_eq(name, b"lastRenderedPageBreak") && in_paragraph {
para_has_rendered_break = true;
} else if qname_eq(name, b"drawing") && in_paragraph {
para_has_drawing = true;
drawing_depth = drawing_depth.saturating_add(1);
} else if (qname_eq(name, b"docPr") || qname_eq(name, b"cNvPr"))
&& in_paragraph
&& drawing_depth > 0
&& para_image_alt.is_none()
{
para_image_alt = harvest_image_alt(&e);
} else if qname_eq(name, b"blip")
&& in_paragraph
&& drawing_depth > 0
&& para_image_rid.is_none()
{
para_image_rid = harvest_blip_embed(&e);
} else if qname_eq(name, b"footnoteReference") && in_paragraph {
if let Some(id) = harvest_note_id(&e) {
para_footnote_refs.push(id);
}
} else if qname_eq(name, b"endnoteReference") && in_paragraph {
if let Some(id) = harvest_note_id(&e) {
para_endnote_refs.push(id);
}
} else if qname_eq(name, b"pStyle") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let v = String::from_utf8_lossy(attr.value.as_ref()).to_string();
para_style = Some(v);
break;
}
}
} else if qname_eq(name, b"ilvl") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw = String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<u8>() {
para_list_level = v;
}
break;
}
}
} else if qname_eq(name, b"numId") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw = String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<u32>() {
para_num_id = Some(v);
}
break;
}
}
} else if qname_eq(name, b"hyperlink") && in_paragraph {
in_hyperlink = true;
hyperlink_text.clear();
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"id") {
hyperlink_rid = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_string();
break;
}
}
} else if qname_eq(name, b"r") && in_paragraph {
in_run = true;
cur_bold = false;
cur_italic = false;
cur_run_text.clear();
} else if qname_eq(name, b"rPr") && in_run {
in_rpr = true;
} else if qname_eq(name, b"outlineLvl") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw = String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<u32>() {
para_outline_lvl = Some(v);
}
break;
}
}
} else if qname_eq(name, b"t") {
in_text = true;
}
}
Ok(Event::Empty(e)) => {
let name = e.name();
if qname_eq(name, b"numPr") && in_paragraph {
para_is_list = true;
} else if qname_eq(name, b"tblHeader") {
if let Some(top) = table_stack.last_mut() {
if top.in_tr_pr {
top.in_header_row = true;
}
}
} else if qname_eq(name, b"gridSpan") {
if let Some(top) = table_stack.last_mut() {
if top.in_cell {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw =
String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<usize>() {
if v > 1 {
top.cell_span = v;
}
}
break;
}
}
}
}
} else if qname_eq(name, b"vMerge") {
if let Some(top) = table_stack.last_mut() {
if top.in_cell {
let mut is_restart = false;
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let v = String::from_utf8_lossy(attr.value.as_ref());
is_restart = v.trim() == "restart";
break;
}
}
top.cur_cell_is_vmerge_continuation = !is_restart;
}
}
} else if qname_eq(name, b"br") && in_paragraph {
let mut is_page = false;
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"type") {
let value = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_ascii_lowercase();
if value == "page" {
para_has_page_break = true;
is_page = true;
}
break;
}
}
if !is_page {
let flushed = std::mem::take(&mut para_text).trim().to_string();
if !flushed.is_empty() {
para_sub_texts.push(flushed);
}
}
} else if qname_eq(name, b"sectPr") && in_paragraph {
para_has_section_break = true;
} else if qname_eq(name, b"lastRenderedPageBreak") && in_paragraph {
para_has_rendered_break = true;
} else if qname_eq(name, b"drawing") && in_paragraph {
para_has_drawing = true;
} else if (qname_eq(name, b"docPr") || qname_eq(name, b"cNvPr"))
&& in_paragraph
&& drawing_depth > 0
&& para_image_alt.is_none()
{
para_image_alt = harvest_image_alt(&e);
} else if qname_eq(name, b"blip")
&& in_paragraph
&& drawing_depth > 0
&& para_image_rid.is_none()
{
para_image_rid = harvest_blip_embed(&e);
} else if qname_eq(name, b"footnoteReference") && in_paragraph {
if let Some(id) = harvest_note_id(&e) {
para_footnote_refs.push(id);
}
} else if qname_eq(name, b"endnoteReference") && in_paragraph {
if let Some(id) = harvest_note_id(&e) {
para_endnote_refs.push(id);
}
} else if qname_eq(name, b"pStyle") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let v = String::from_utf8_lossy(attr.value.as_ref()).to_string();
para_style = Some(v);
break;
}
}
} else if qname_eq(name, b"ilvl") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw = String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<u8>() {
para_list_level = v;
}
break;
}
}
} else if qname_eq(name, b"numId") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw = String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<u32>() {
para_num_id = Some(v);
}
break;
}
}
} else if qname_eq(name, b"outlineLvl") && in_paragraph {
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
let raw = String::from_utf8_lossy(attr.value.as_ref()).to_string();
if let Ok(v) = raw.trim().parse::<u32>() {
para_outline_lvl = Some(v);
}
break;
}
}
} else if qname_eq(name, b"b") && in_rpr {
let mut val = String::new();
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
val = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_string();
break;
}
}
cur_bold = val != "false" && val != "0";
} else if qname_eq(name, b"i") && in_rpr {
let mut val = String::new();
for attr in e.attributes().flatten() {
if qname_eq(attr.key, b"val") {
val = String::from_utf8_lossy(attr.value.as_ref())
.trim()
.to_string();
break;
}
}
cur_italic = val != "false" && val != "0";
}
}
Ok(Event::End(e)) => {
let name = e.name();
if qname_eq(name, b"t") {
in_text = false;
} else if qname_eq(name, b"drawing") {
drawing_depth = drawing_depth.saturating_sub(1);
} else if qname_eq(name, b"hyperlink") && in_paragraph {
let anchor = hyperlink_text.trim().to_string();
if !anchor.is_empty() && !hyperlink_rid.is_empty() {
para_hyperlinks.push((anchor, hyperlink_rid.clone()));
}
in_hyperlink = false;
hyperlink_rid.clear();
hyperlink_text.clear();
} else if qname_eq(name, b"rPr") {
in_rpr = false;
} else if qname_eq(name, b"r") && in_paragraph {
if !cur_run_text.is_empty() {
let formatted = match (cur_bold, cur_italic) {
(true, true) => format!("***{}***", cur_run_text),
(true, false) => format!("**{}**", cur_run_text),
(false, true) => format!("*{}*", cur_run_text),
(false, false) => cur_run_text.clone(),
};
push_text(&mut para_text, &formatted);
}
in_run = false;
in_rpr = false;
cur_bold = false;
cur_italic = false;
cur_run_text.clear();
} else if qname_eq(name, b"trPr") {
if let Some(top) = table_stack.last_mut() {
top.in_tr_pr = false;
}
} else if qname_eq(name, b"tc") {
if let Some(top) = table_stack.last_mut() {
let col_index = top.current_row.len();
let raw_cell = std::mem::take(&mut top.current_cell).trim().to_string();
let cell = if top.cur_cell_is_vmerge_continuation {
top.vmerge_col_content
.get(col_index)
.cloned()
.unwrap_or_default()
} else {
raw_cell.clone()
};
let span = top.cell_span.max(1);
for i in 0..span {
let abs_col = col_index + i;
if !top.cur_cell_is_vmerge_continuation {
if top.vmerge_col_content.len() <= abs_col {
top.vmerge_col_content.resize(abs_col + 1, String::new());
}
top.vmerge_col_content[abs_col] = raw_cell.clone();
}
top.current_row.push(cell.clone());
}
top.in_cell = false;
top.cell_span = 1;
top.cur_cell_is_vmerge_continuation = false;
}
} else if qname_eq(name, b"tr") {
if let Some(top) = table_stack.last_mut() {
if !top.current_row.is_empty() {
let row = std::mem::take(&mut top.current_row);
top.header_row_flags.push(top.in_header_row);
top.rows.push(row);
}
top.in_header_row = false;
top.in_tr_pr = false;
}
} else if qname_eq(name, b"tbl") {
if let Some(state) = table_stack.pop() {
if let Some(parent) = table_stack.last_mut() {
let inline = render_table_inline(&state);
if !inline.is_empty() {
if !parent.current_cell.is_empty()
&& !parent.current_cell.ends_with(' ')
{
parent.current_cell.push(' ');
}
parent.current_cell.push_str(&inline);
}
} else {
let rendered = render_table_markdown(&state);
if !rendered.is_empty() {
blocks.push(DocxBlock {
kind: DocxBlockKind::Table,
text: rendered,
has_drawing: false,
is_list: false,
list_level: 0,
heading_style: None,
outline_level: None,
page_break: false,
section_break: false,
rendered_page_break: false,
image_alt: None,
image_rid: None,
footnote_refs: Vec::new(),
endnote_refs: Vec::new(),
num_id: None,
hyperlinks: Vec::new(),
});
}
}
}
} else if qname_eq(name, b"p") {
if in_paragraph {
let style_ref = para_style.as_deref().unwrap_or("");
let is_list = para_is_list || is_list_style(style_ref);
if para_sub_texts.is_empty() {
blocks.push(DocxBlock {
kind: DocxBlockKind::Paragraph,
text: std::mem::take(&mut para_text),
has_drawing: para_has_drawing,
is_list,
list_level: para_list_level,
heading_style: para_style.take(),
outline_level: para_outline_lvl.take(),
page_break: para_has_page_break,
section_break: para_has_section_break,
rendered_page_break: para_has_rendered_break,
image_alt: para_image_alt.take(),
image_rid: para_image_rid.take(),
footnote_refs: std::mem::take(&mut para_footnote_refs),
endnote_refs: std::mem::take(&mut para_endnote_refs),
num_id: para_num_id,
hyperlinks: std::mem::take(&mut para_hyperlinks),
});
} else {
let tail = std::mem::take(&mut para_text).trim().to_string();
if !tail.is_empty() {
para_sub_texts.push(tail);
}
for (i, sub_text) in para_sub_texts.drain(..).enumerate() {
blocks.push(DocxBlock {
kind: DocxBlockKind::Paragraph,
text: sub_text,
has_drawing: para_has_drawing && i == 0,
is_list,
list_level: para_list_level,
heading_style: para_style.clone(),
outline_level: para_outline_lvl,
page_break: para_has_page_break && i == 0,
section_break: para_has_section_break && i == 0,
rendered_page_break: para_has_rendered_break && i == 0,
image_alt: if i == 0 { para_image_alt.clone() } else { None },
image_rid: if i == 0 { para_image_rid.clone() } else { None },
footnote_refs: if i == 0 {
para_footnote_refs.clone()
} else {
Vec::new()
},
endnote_refs: if i == 0 {
para_endnote_refs.clone()
} else {
Vec::new()
},
num_id: para_num_id,
hyperlinks: if i == 0 {
para_hyperlinks.clone()
} else {
Vec::new()
},
});
}
para_style = None;
para_outline_lvl = None;
para_image_alt = None;
para_footnote_refs.clear();
para_endnote_refs.clear();
para_hyperlinks.clear();
}
in_paragraph = false;
para_is_list = false;
para_list_level = 0;
para_num_id = None;
in_hyperlink = false;
hyperlink_rid.clear();
hyperlink_text.clear();
para_has_drawing = false;
para_has_page_break = false;
para_has_section_break = false;
para_has_rendered_break = false;
para_image_rid = None;
drawing_depth = 0;
in_run = false;
in_rpr = false;
cur_bold = false;
cur_italic = false;
cur_run_text.clear();
} else if let Some(top) = table_stack.last_mut() {
if top.in_cell
&& !top.current_cell.is_empty()
&& !top.current_cell.ends_with(' ')
{
top.current_cell.push(' ');
}
}
}
}
Ok(Event::Text(t)) => {
if in_text {
let txt = match t.decode() {
Ok(v) => v.into_owned(),
Err(_) => String::new(),
};
if let Some(top) = table_stack.last_mut() {
if top.in_cell {
push_text(&mut top.current_cell, &txt);
}
} else if in_paragraph {
if in_run {
push_text(&mut cur_run_text, &txt);
if in_hyperlink {
push_text(&mut hyperlink_text, &txt);
}
} else {
push_text(&mut para_text, &txt);
if in_hyperlink {
push_text(&mut hyperlink_text, &txt);
}
}
}
}
}
Ok(Event::CData(t)) => {
if in_text {
let txt = String::from_utf8_lossy(t.as_ref());
if let Some(top) = table_stack.last_mut() {
if top.in_cell {
push_text(&mut top.current_cell, &txt);
}
} else if in_paragraph {
if in_run {
push_text(&mut cur_run_text, &txt);
if in_hyperlink {
push_text(&mut hyperlink_text, &txt);
}
} else {
push_text(&mut para_text, &txt);
if in_hyperlink {
push_text(&mut hyperlink_text, &txt);
}
}
}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(format!("Failed to parse word/document.xml stream: {e}")),
_ => {}
}
buf.clear();
}
Ok(blocks)
}
pub(super) fn open_docx_archive_with_rids(
bytes: &[u8],
) -> Result<(zip::ZipArchive<std::io::Cursor<Vec<u8>>>, std::collections::HashMap<String, String>), String> {
use std::io::Read;
let cursor = std::io::Cursor::new(bytes.to_vec());
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("Not a valid DOCX ZIP: {e}"))?;
let image_rids_map = match archive.by_name("word/_rels/document.xml.rels") {
Ok(mut f) => {
let mut xml = String::new();
let _ = f.read_to_string(&mut xml);
parse_rels_xml_images(&xml)
}
Err(_) => std::collections::HashMap::new(),
};
Ok((archive, image_rids_map))
}
pub(super) fn collect_image_chunks_from_items(
image_items: Vec<(Option<String>, Option<String>)>,
image_rids_map: &HashMap<String, String>,
archive: &mut ZipArchive<Cursor<Vec<u8>>>,
) -> (Vec<(String, serde_json::Value)>, Vec<(String, Vec<u8>)>) {
let mut image_out: Vec<(String, Vec<u8>)> = Vec::new();
let mut entries: Vec<(String, serde_json::Value)> = Vec::new();
for (rid, alt) in image_items {
let Some(rid) = rid else { continue };
let Some(zip_path) = image_rids_map.get(&rid) else { continue };
if let Ok(mut entry) = archive.by_name(zip_path) {
let mut img_bytes = Vec::new();
if entry.read_to_end(&mut img_bytes).is_ok() {
if let Some(hash_name) = image_hash_name(&img_bytes, zip_path) {
if !image_out.iter().any(|(n, _)| n == &hash_name) {
image_out.push((hash_name.clone(), img_bytes));
}
let alt_str = alt.as_deref().unwrap_or("");
entries.push((
hash_name.clone(),
serde_json::json!({ "image_name": hash_name, "alt_text": alt_str }),
));
}
}
}
}
(entries, image_out)
}