rs-chunks 0.6.1

Fast, high-fidelity document chunking for RAG — a pure-Rust engine covering 36 file formats (Office, OpenDocument, PDF, email, ebooks, notebooks, and more).
//! The slide XML parser and whole-deck slide reading.

use quick_xml::events::attributes::Attributes;
use quick_xml::events::Event;
use quick_xml::Reader;
use std::io::BufReader;

use crate::entities::read_event_folding_entities;

use super::archive::{find_notes_for_slide, parse_notes_xml, read_zip_entry, PptxArchive};
use super::slide_model::SlideContent;
use super::xml_util::local_name;

// ── Slide XML parser ──────────────────────────────────────────────────────────

/// `<dgm:relIds r:dm="rId2" r:lo=… r:qs=… r:cs=…/>` — only `dm` (the *data
/// model*) carries text; the others are layout, quick-style and colours.
fn diagram_data_rid(attrs: Attributes<'_>) -> Option<String> {
    for attr in attrs.flatten() {
        let key = attr.key.as_ref();
        let local = key.rsplit(|b| *b == b':').next().unwrap_or(key);
        if local == b"dm" {
            let v = attr.unescape_value().ok()?.trim().to_string();
            return (!v.is_empty()).then_some(v);
        }
    }
    None
}

/// `<c:chart r:id="rId2"/>` inside a `<a:graphicData>` — the pointer to the
/// part holding the plotted data.
fn chart_rel_id(attrs: Attributes<'_>) -> Option<String> {
    for attr in attrs.flatten() {
        let key = attr.key.as_ref();
        let local = key.rsplit(|b| *b == b':').next().unwrap_or(key);
        if local == b"id" {
            let v = attr.unescape_value().ok()?.trim().to_string();
            return (!v.is_empty()).then_some(v);
        }
    }
    None
}

pub fn parse_slide_xml(xml_bytes: &[u8]) -> Result<SlideContent, String> {
    let mut reader = Reader::from_reader(BufReader::new(xml_bytes));
    let mut buf = Vec::new();
    let mut slide = SlideContent::default();
    let mut sp_depth: i32 = 0;
    let mut sp_is_title = false;
    let mut sp_ph_checked = false;
    let mut in_txbody = false;
    let mut in_para = false;
    let mut para_text = String::new();
    let mut shape_paragraphs: Vec<String> = Vec::new();
    let mut t_buf = String::new();
    let mut in_t = false;
    // ── Table-cell extraction state ──────────────────────────────────────────
    let mut in_tbl = false;
    let mut in_tc = false; // inside <a:tc>
    let mut in_tc_body = false; // inside table cell's <a:txBody>
    let mut in_tc_para = false; // inside table cell's <a:p>
    let mut tc_para_text = String::new();
    let mut tc_cell_paras: Vec<String> = Vec::new();
    let mut table_row_cells: Vec<String> = Vec::new();
    let mut table_all_rows: Vec<String> = Vec::new();

    loop {
        // Entity references arrive as their own event; fold them back into text.
        let mut spill = String::new();
        let mut is_entity = false;
        match read_event_folding_entities!(reader, &mut buf, &mut spill, &mut is_entity) {
            Ok(Event::Eof) => break,
            Err(e) => return Err(format!("XML parse error in slide: {e}")),
            Ok(Event::Start(ref e)) => {
                let local = local_name(e.name());
                match local.as_slice() {
                    b"sp" => {
                        sp_depth += 1;
                        if sp_depth == 1 {
                            sp_is_title = false;
                            sp_ph_checked = false;
                            shape_paragraphs.clear();
                        }
                    }
                    b"ph" if sp_depth > 0 && !sp_ph_checked => {
                        sp_ph_checked = true;
                        let (mut ph_type, mut ph_idx) = (None::<String>, None::<String>);
                        for attr in e.attributes().flatten() {
                            let aname = attr.key.as_ref();
                            let local = aname.rsplit(|b| *b == b':').next().unwrap_or(aname);
                            match local {
                                b"type" => {
                                    ph_type =
                                        attr.unescape_value().ok().map(|v| v.trim().to_string())
                                }
                                b"idx" => {
                                    ph_idx =
                                        attr.unescape_value().ok().map(|v| v.trim().to_string())
                                }
                                _ => {}
                            }
                        }
                        if let Some(t) = ph_type {
                            let t_lower = t.to_ascii_lowercase();
                            sp_is_title =
                                matches!(t_lower.as_str(), "title" | "ctrtitle" | "subtitle");
                        } else if ph_idx.as_deref() == Some("0") {
                            // No type attribute but idx=0 is the title placeholder by convention.
                            sp_is_title = true;
                        }
                    }
                    b"txBody" if sp_depth > 0 => in_txbody = true,
                    b"p" if in_txbody => {
                        in_para = true;
                        para_text.clear();
                    }
                    b"t" if in_para || in_tc_para => {
                        in_t = true;
                        t_buf.clear();
                    }
                    b"tbl" => {
                        slide.has_table = true;
                        in_tbl = true;
                        table_all_rows.clear();
                    }
                    b"tr" if in_tbl => table_row_cells.clear(),
                    b"tc" if in_tbl => {
                        in_tc = true;
                        tc_cell_paras.clear();
                    }
                    b"txBody" if in_tc => in_tc_body = true,
                    b"p" if in_tc_body => {
                        in_tc_para = true;
                        tc_para_text.clear();
                    }
                    // A SmartArt graphic's text is not in this file at all —
                    // only the pointer to the part that holds it.
                    b"relIds" => {
                        if let Some(rid) = diagram_data_rid(e.attributes()) {
                            slide.diagram_rids.push(rid);
                        }
                    }
                    // A chart contributes no text to the slide either — only
                    // the pointer to the part holding its numbers.
                    b"chart" => {
                        if let Some(rid) = chart_rel_id(e.attributes()) {
                            slide.chart_rids.push(rid);
                        }
                    }
                    _ => {}
                }
            }
            Ok(Event::Empty(ref e)) => {
                let local = local_name(e.name());
                if local.as_slice() == b"relIds" {
                    if let Some(rid) = diagram_data_rid(e.attributes()) {
                        slide.diagram_rids.push(rid);
                    }
                }
                // `<c:chart r:id=…/>` is self-closing, so this is the arm that
                // actually fires for every chart in our fixtures.
                if local.as_slice() == b"chart" {
                    if let Some(rid) = chart_rel_id(e.attributes()) {
                        slide.chart_rids.push(rid);
                    }
                }
                if local.as_slice() == b"ph" && sp_depth > 0 && !sp_ph_checked {
                    sp_ph_checked = true;
                    let (mut ph_type, mut ph_idx) = (None::<String>, None::<String>);
                    for attr in e.attributes().flatten() {
                        let aname = attr.key.as_ref();
                        let local_attr = aname.rsplit(|b| *b == b':').next().unwrap_or(aname);
                        match local_attr {
                            b"type" => {
                                ph_type = attr.unescape_value().ok().map(|v| v.trim().to_string())
                            }
                            b"idx" => {
                                ph_idx = attr.unescape_value().ok().map(|v| v.trim().to_string())
                            }
                            _ => {}
                        }
                    }
                    if let Some(t) = ph_type {
                        let t_lower = t.to_ascii_lowercase();
                        sp_is_title = matches!(t_lower.as_str(), "title" | "ctrtitle" | "subtitle");
                    } else if ph_idx.as_deref() == Some("0") {
                        sp_is_title = true;
                    }
                }
            }
            Ok(Event::Text(ref e)) => {
                if in_t {
                    // One <a:t> arrives as several events when it contains
                    // entity references, so concatenate verbatim here and let
                    // the flush at </a:t> do the trimming and joining. Trimming
                    // per event would put a space inside a word.
                    t_buf.push_str(e.decode().unwrap_or_default().as_ref());
                }
            }
            Ok(Event::End(ref e)) => {
                let local = local_name(e.name());
                match local.as_slice() {
                    b"t" if in_t => {
                        in_t = false;
                        let t_buf = std::mem::take(&mut t_buf).trim().to_string();
                        if !t_buf.is_empty() {
                            // Route accumulated text to the correct buffer.
                            if in_tc_para {
                                if !tc_para_text.is_empty() {
                                    tc_para_text.push(' ');
                                }
                                tc_para_text.push_str(&t_buf);
                            } else {
                                if !para_text.is_empty() {
                                    para_text.push(' ');
                                }
                                para_text.push_str(&t_buf);
                            }
                        }
                    }
                    b"p" if in_para => {
                        in_para = false;
                        let trimmed = para_text.trim().to_string();
                        if !trimmed.is_empty() {
                            shape_paragraphs.push(trimmed);
                        }
                        para_text.clear();
                    }
                    b"txBody" if in_txbody => in_txbody = false,
                    // ── Table-cell end events ──────────────────────────────────────────
                    b"p" if in_tc_para => {
                        in_tc_para = false;
                        let trimmed = tc_para_text.trim().to_string();
                        if !trimmed.is_empty() {
                            tc_cell_paras.push(trimmed);
                        }
                        tc_para_text.clear();
                    }
                    b"txBody" if in_tc_body => in_tc_body = false,
                    b"tc" if in_tc => {
                        in_tc = false;
                        let cell_text = tc_cell_paras.join(" ").trim().to_string();
                        table_row_cells.push(cell_text);
                        tc_cell_paras.clear();
                    }
                    b"tr" if in_tbl => {
                        let row = table_row_cells
                            .iter()
                            .filter(|c| !c.trim().is_empty())
                            .cloned()
                            .collect::<Vec<_>>()
                            .join(" | ");
                        if !row.trim().is_empty() {
                            table_all_rows.push(row);
                        }
                        table_row_cells.clear();
                    }
                    b"tbl" if in_tbl => {
                        in_tbl = false;
                        let table_text = table_all_rows
                            .iter()
                            .filter(|r| !r.trim().is_empty())
                            .cloned()
                            .collect::<Vec<_>>()
                            .join("\n");
                        if !table_text.is_empty() {
                            slide.body_paragraphs.push(table_text);
                        }
                        table_all_rows.clear();
                    }
                    b"sp" if sp_depth > 0 => {
                        sp_depth -= 1;
                        if sp_depth == 0 {
                            let combined = shape_paragraphs.join("\n").trim().to_string();
                            if !combined.is_empty() {
                                if sp_is_title && slide.title.is_none() {
                                    slide.title = Some(combined);
                                } else {
                                    slide.body_paragraphs.push(combined);
                                }
                            }
                            shape_paragraphs.clear();
                            sp_is_title = false;
                            sp_ph_checked = false;
                        }
                    }
                    _ => {}
                }
            }
            _ => {}
        }
        buf.clear();
    }
    Ok(slide)
}

/// Read and parse all slides from the archive. Returns (slide_number, SlideContent) pairs
/// in sorted order; does not filter out empty slides — callers decide what to skip.
pub fn read_all_slides(
    archive: &mut PptxArchive,
    slide_names: &[(usize, String)],
) -> Result<Vec<(usize, SlideContent)>, String> {
    let mut slides = Vec::with_capacity(slide_names.len());
    for (slide_num, name) in slide_names {
        let xml_bytes = read_zip_entry(archive, name)?;
        let mut slide = parse_slide_xml(&xml_bytes)?;
        // SmartArt keeps its text in a sibling part, so pull it in and append it
        // to the body — otherwise every diagram label is silently dropped.
        // Best-effort: a broken diagram must not fail the slide.
        for part in super::diagram::resolve_diagram_parts(archive, name, &slide.diagram_rids) {
            if let Ok(bytes) = read_zip_entry(archive, &part) {
                slide
                    .body_paragraphs
                    .extend(super::diagram::parse_diagram_xml(&bytes));
            }
        }
        // Charts, same shape. The rows are pushed as body paragraphs so every
        // chunking mode picks them up through `all_text()`.
        for part in super::chart::resolve_chart_parts(archive, name, &slide.chart_rids) {
            if let Ok(bytes) = read_zip_entry(archive, &part) {
                let rows = super::chart::parse_chart_xml(&bytes);
                if !rows.is_empty() {
                    slide.body_paragraphs.push("Chart".to_string());
                    for row in rows {
                        slide.body_paragraphs.push(row.join(" | "));
                    }
                }
            }
        }
        // Load speaker notes via the slide's .rels file (best-effort; ignore failures).
        if let Some(notes_path) = find_notes_for_slide(archive, name) {
            if let Ok(notes_bytes) = read_zip_entry(archive, &notes_path) {
                slide.notes_text = parse_notes_xml(&notes_bytes);
            }
        }
        slides.push((*slide_num, slide));
    }
    Ok(slides)
}