Skip to main content

docparser_office/
pptx.rs

1//! PPTX 解析:ZIP + quick-xml 流式文本提取。
2//!
3//! 架构:`PptxRawBackend` -> `ParseCapability`,支持 slide 排序、形状文本提取、
4//! 组合形状递归、图表/表格/备注 slide。
5
6use docparser_core::error::{DocError, Result};
7use docparser_core::plugin::{ParseCapability, RawDoc, RawPage};
8use quick_xml::events::Event;
9use quick_xml::Reader;
10
11use crate::parse_rels;
12use std::collections::HashMap;
13
14/// PPTX raw 后端。
15pub struct PptxRawBackend;
16
17impl ParseCapability for PptxRawBackend {
18    fn id(&self) -> &str {
19        "office-raw"
20    }
21    fn formats(&self) -> &[&str] {
22        &["pptx"]
23    }
24    fn parse(&self, path: &str) -> Result<RawDoc> {
25        let file = std::fs::File::open(path)
26            .map_err(|e| DocError::corrupt(format!("cannot open {path}: {e}")))?;
27        let mut zip = zip::ZipArchive::new(file)
28            .map_err(|e| DocError::corrupt(format!("ZIP open failed: {e}")))?;
29
30        // 全量缓存所有 XML 和 rels 文件,避免 per-slide 多次 ZIP 遍历
31        let mut cache: HashMap<String, String> = HashMap::new();
32        for i in 0..zip.len() {
33            let mut f = zip
34                .by_index(i)
35                .map_err(|e| DocError::corrupt(format!("ZIP entry error at index {i}: {e}")))?;
36            // zip 9:条目名改为 Result<Cow<str>, ZipError>(非法 UTF-8 名不再静默)
37            let name = f
38                .name()
39                .map_err(|e| DocError::corrupt(format!("ZIP entry name error at index {i}: {e}")))?
40                .to_string();
41            if name.ends_with(".xml") || name.ends_with(".rels") {
42                let mut buf = Vec::new();
43                std::io::Read::read_to_end(&mut f, &mut buf)
44                    .map_err(|e| DocError::corrupt(format!("读取 {name} 失败: {e}")))?;
45                let s = String::from_utf8(buf)
46                    .map_err(|e| DocError::corrupt(format!("{name} 不是 UTF-8: {e}")))?;
47                cache.insert(name, s);
48            }
49        }
50
51        // 读取 slide 列表
52        let pres_xml = cache
53            .get("ppt/presentation.xml")
54            .ok_or_else(|| DocError::corrupt("missing ppt/presentation.xml"))?;
55        let slide_rids = parse_slide_list(pres_xml)?;
56
57        // 读取 rels 获取 slide 路径
58        let pres_rels = cache
59            .get("ppt/_rels/presentation.xml.rels")
60            .map(|s| s.as_str())
61            .unwrap_or("");
62        let rels_map = parse_rels(pres_rels);
63
64        // 获取所有的 slide 文件路径(按文档顺序)
65        let slide_paths: Vec<String> = slide_rids
66            .iter()
67            .filter_map(|rid| {
68                let target = rels_map.get(rid)?;
69                // target 可能是 "slides/slide1.xml" 或 "./slides/slide1.xml"
70                let cleaned = target.trim_start_matches("./");
71                Some(format!("ppt/{}", cleaned))
72            })
73            .collect();
74
75        if slide_paths.is_empty() {
76            return Err(DocError::corrupt("no slides found in presentation.xml"));
77        }
78
79        // 解析每个 slide
80        let mut pages = Vec::new();
81        for (idx, slide_path) in slide_paths.iter().enumerate() {
82            let xml = cache.get(slide_path).cloned().unwrap_or_default();
83
84            // 提取 slide 文本(带位置)和 chart 图形框位置
85            let (mut pos_items, chart_positions) = extract_slide_items(&xml)?;
86
87            // 从文件路径 "ppt/slides/slideN.xml" 提取 N(用于备注/关系映射)
88            let slide_num: usize = slide_path
89                .trim_start_matches("ppt/slides/slide")
90                .trim_end_matches(".xml")
91                .parse()
92                .unwrap_or(idx + 1);
93
94            // 1. 演讲者备注(跳过 slide number 等纯数字占位符,放在最后)
95            let notes_path = format!("ppt/notesSlides/notesSlide{}.xml", slide_num);
96            if let Some(notes_xml) = cache.get(&notes_path) {
97                if let Ok(notes_texts) = extract_slide_texts(notes_xml) {
98                    for t in notes_texts {
99                        let trimmed = t.trim();
100                        if !trimmed.is_empty() && !trimmed.chars().all(|c| c.is_ascii_digit()) {
101                            pos_items.push((i64::MAX, i64::MAX, trimmed.to_string()));
102                        }
103                    }
104                }
105            }
106
107            // 2. 图表文本(带 chart 图形框的位置)
108            let rels_path = format!("ppt/slides/_rels/slide{}.xml.rels", slide_num);
109            if let Some(rels_xml) = cache.get(&rels_path) {
110                let chart_targets = find_chart_targets(rels_xml);
111                // 配对 chart 文本和位置
112                for (ci, target) in chart_targets.iter().enumerate() {
113                    let chart_path = resolve_rels_target(slide_path, target);
114                    if let Some(chart_xml) = cache.get(&chart_path) {
115                        let (c_y, c_x) = chart_positions.get(ci).copied().unwrap_or((0, 0));
116                        if let Ok(chart_texts) = extract_chart_texts(chart_xml) {
117                            for t in chart_texts {
118                                if !t.trim().is_empty() {
119                                    pos_items.push((c_y, c_x, t));
120                                }
121                            }
122                        }
123                    }
124                }
125            }
126
127            // 按位置 (y, x) 排序,备注在最后
128            pos_items.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
129
130            let items: Vec<docparser_core::plugin::RawTextItem> = pos_items
131                .into_iter()
132                .map(|(_, _, text)| docparser_core::plugin::RawTextItem { text, bbox: None })
133                .collect();
134
135            if !items.is_empty() {
136                pages.push(RawPage {
137                    name: Some(format!("Slide {}", idx + 1)),
138                    items,
139                    ..Default::default()
140                });
141            }
142        }
143
144        Ok(RawDoc {
145            pages,
146            ..Default::default()
147        })
148    }
149}
150
151/// 解析 `<sldIdLst>` 获取 slide 的 r:id 列表(保持文档顺序)。
152fn parse_slide_list(xml: &str) -> Result<Vec<String>> {
153    let mut reader = Reader::from_str(xml);
154    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
155    let mut buf = Vec::new();
156    let mut rids = Vec::new();
157
158    loop {
159        match reader.read_event_into(&mut buf) {
160            Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => {
161                if e.name().local_name().as_ref() == crate::tags::P_SLDID {
162                    for attr in e.attributes().filter_map(|a| a.ok()) {
163                        // 属性按完整 QName 比较:只认 r:id,`sldId` 自己的裸 id 不算关系 id
164                        if attr.key.as_ref() == "r:id" {
165                            rids.push(attr.value.to_string());
166                        }
167                    }
168                }
169            }
170            Ok(Event::End(ref e)) => {
171                if e.name().local_name().as_ref() == "presentation" {
172                    break;
173                }
174            }
175            Ok(Event::Eof) => break,
176            Err(e) => return Err(DocError::corrupt(format!("presentation parse error: {e}"))),
177            _ => {}
178        }
179        buf.clear();
180    }
181    Ok(rids)
182}
183
184/// `(y, x, text)` 文本项 + chart 图形框位置。
185type SlideItems = (Vec<(i64, i64, String)>, Vec<(i64, i64)>);
186
187/// 提取 slide 文本(带形状位置)和 chart 图形框的位置。
188///
189/// 返回 `(text_items, chart_positions)`:
190/// - `text_items`:`(y, x, text)`,按形状在 XML 中出现的顺序
191/// - `chart_positions`:`(y, x)`,对应 chart rel 顺序的图形框位置
192fn extract_slide_items(xml: &str) -> Result<SlideItems> {
193    let mut reader = Reader::from_str(xml);
194    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
195    let mut buf = Vec::new();
196    let mut items = Vec::new();
197    let mut chart_positions = Vec::new();
198
199    let mut shape_depth: u32 = 0;
200    let mut cur_y: i64 = 0;
201    let mut cur_x: i64 = 0;
202    let mut in_t = false;
203    let mut text_buf = String::new();
204    let mut shape_texts: Vec<String> = Vec::new();
205    let mut is_chart_frame = false;
206    // 表格结构追踪
207    let mut in_tbl = false;
208    let mut in_tr = false;
209    let mut in_tc = false;
210    let mut cell_text = String::new();
211    let mut row_cells: Vec<String> = Vec::new();
212    let mut table_rows: Vec<String> = Vec::new();
213
214    loop {
215        match reader.read_event_into(&mut buf) {
216            Ok(Event::Start(ref e)) => {
217                let name = e.name().local_name().as_ref().to_string();
218
219                if matches!(
220                    name.as_str(),
221                    crate::tags::P_SP
222                        | crate::tags::P_GFRAME
223                        | crate::tags::P_GRPSP
224                        | crate::tags::P_CXNSP
225                ) {
226                    if shape_depth == 0 {
227                        cur_x = 0;
228                        cur_y = 0;
229                        shape_texts.clear();
230                        is_chart_frame = name == crate::tags::P_GFRAME;
231                    }
232                    shape_depth += 1;
233                }
234
235                if shape_depth > 0 {
236                    if name == crate::tags::A_OFF || name == crate::tags::A_POS {
237                        for attr in e.attributes().filter_map(|a| a.ok()) {
238                            match attr.key.as_ref() {
239                                "y" => cur_y = attr.value.parse().unwrap_or(0),
240                                "x" => cur_x = attr.value.parse().unwrap_or(0),
241                                _ => {}
242                            }
243                        }
244                    }
245
246                    if name == crate::tags::A_T {
247                        in_t = true;
248                        text_buf.clear();
249                    } else if name == crate::tags::A_BR
250                        && (!text_buf.is_empty() || !shape_texts.is_empty())
251                    {
252                        shape_texts.push(std::mem::take(&mut text_buf));
253                    } else if name == crate::tags::A_TBL {
254                        in_tbl = true;
255                        table_rows.clear();
256                    } else if name == crate::tags::A_TR {
257                        in_tr = true;
258                        row_cells.clear();
259                    } else if name == crate::tags::A_TC {
260                        in_tc = true;
261                        cell_text.clear();
262                    }
263                }
264            }
265            Ok(Event::Empty(ref e)) => {
266                let name = e.name().local_name().as_ref().to_string();
267                if shape_depth > 0 {
268                    if name == crate::tags::A_OFF || name == crate::tags::A_POS {
269                        for attr in e.attributes().filter_map(|a| a.ok()) {
270                            match attr.key.as_ref() {
271                                "y" => cur_y = attr.value.parse().unwrap_or(0),
272                                "x" => cur_x = attr.value.parse().unwrap_or(0),
273                                _ => {}
274                            }
275                        }
276                    }
277                    if name == crate::tags::A_T {
278                        in_t = true;
279                        text_buf.clear();
280                    } else if name == crate::tags::A_BR
281                        && (!text_buf.is_empty() || !shape_texts.is_empty())
282                    {
283                        shape_texts.push(std::mem::take(&mut text_buf));
284                    }
285                }
286            }
287            Ok(Event::End(ref e)) => {
288                let name = e.name().local_name().as_ref().to_string();
289                if shape_depth > 0 {
290                    if name == crate::tags::A_T {
291                        in_t = false;
292                        let trimmed = text_buf.trim().to_string();
293                        if !trimmed.is_empty() {
294                            if in_tc {
295                                // 表格单元格内:accumulate for row joining
296                                if !cell_text.is_empty() {
297                                    cell_text.push('\n');
298                                }
299                                cell_text.push_str(&trimmed);
300                            } else {
301                                shape_texts.push(trimmed);
302                            }
303                        }
304                        text_buf.clear();
305                    } else if name == crate::tags::A_TC {
306                        if in_tc {
307                            row_cells.push(std::mem::take(&mut cell_text));
308                            in_tc = false;
309                        }
310                    } else if name == crate::tags::A_TR {
311                        if in_tr {
312                            let row_str = row_cells.join(",");
313                            table_rows.push(row_str);
314                            row_cells.clear();
315                            in_tr = false;
316                        }
317                    } else if name == crate::tags::A_TBL {
318                        in_tbl = false;
319                    }
320
321                    if matches!(
322                        name.as_str(),
323                        crate::tags::P_SP
324                            | crate::tags::P_GFRAME
325                            | crate::tags::P_GRPSP
326                            | crate::tags::P_CXNSP
327                    ) {
328                        shape_depth -= 1;
329                        if shape_depth == 0 {
330                            // 只有非表格的 graphicFrame 才计入 chart_positions
331                            if is_chart_frame && !in_tbl {
332                                chart_positions.push((cur_y, cur_x));
333                            }
334                            if !table_rows.is_empty() {
335                                // 表格行输出
336                                for t in &table_rows {
337                                    if !t.is_empty() {
338                                        items.push((cur_y, cur_x, t.clone()));
339                                    }
340                                }
341                                table_rows.clear();
342                            } else {
343                                for t in &shape_texts {
344                                    if !t.is_empty() {
345                                        items.push((cur_y, cur_x, t.clone()));
346                                    }
347                                }
348                            }
349                            shape_texts.clear();
350                        }
351                    }
352                }
353            }
354            Ok(Event::Text(ref e)) => {
355                if in_t {
356                    // quick-xml 0.38+:Text 已是解码后的字符串,实体需显式 unescape
357                    let raw = e.xml10_content();
358                    if let Ok(t) = quick_xml::escape::unescape(&raw) {
359                        text_buf.push_str(&t);
360                    }
361                }
362            }
363            Ok(Event::GeneralRef(ref e)) => {
364                if in_t {
365                    crate::push_general_ref(&mut text_buf, e);
366                }
367            }
368            Ok(Event::CData(ref e)) => {
369                if in_t {
370                    text_buf.push_str(e.as_ref());
371                }
372            }
373            Ok(Event::Eof) => break,
374            Err(e) => return Err(DocError::corrupt(format!("slide parse error: {e}"))),
375            _ => {}
376        }
377        buf.clear();
378    }
379
380    Ok((items, chart_positions))
381}
382
383/// 提取备注 slide 中的纯文本(用于 `extract_slide_items` 之外的场景,如 notes)。
384fn extract_slide_texts(xml: &str) -> Result<Vec<String>> {
385    let mut reader = Reader::from_str(xml);
386    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
387    let mut buf = Vec::new();
388    let mut texts = Vec::new();
389    let mut in_t = false;
390    let mut text_buf = String::new();
391
392    loop {
393        match reader.read_event_into(&mut buf) {
394            Ok(Event::Start(ref e)) => {
395                let name = e.name().local_name().as_ref().to_string();
396                if name == crate::tags::A_T {
397                    in_t = true;
398                    text_buf.clear();
399                } else if name == crate::tags::A_BR && (!text_buf.is_empty() || !texts.is_empty()) {
400                    // 换行符
401                    texts.push(std::mem::take(&mut text_buf));
402                }
403            }
404            Ok(Event::Empty(ref e)) => {
405                let name = e.name().local_name().as_ref().to_string();
406                if name == crate::tags::A_T {
407                    in_t = true;
408                    text_buf.clear();
409                } else if name == crate::tags::A_BR && (!text_buf.is_empty() || !texts.is_empty()) {
410                    texts.push(std::mem::take(&mut text_buf));
411                }
412            }
413            Ok(Event::End(ref e)) => {
414                let name = e.name().local_name().as_ref().to_string();
415                if name == crate::tags::A_T {
416                    in_t = false;
417                    let trimmed = text_buf.trim().to_string();
418                    if !trimmed.is_empty() {
419                        texts.push(trimmed);
420                    }
421                    text_buf.clear();
422                }
423            }
424            Ok(Event::Text(ref e)) => {
425                if in_t {
426                    // quick-xml 0.38+:Text 已是解码后的字符串,实体需显式 unescape
427                    let raw = e.xml10_content();
428                    if let Ok(t) = quick_xml::escape::unescape(&raw) {
429                        text_buf.push_str(&t);
430                    }
431                }
432            }
433            Ok(Event::GeneralRef(ref e)) => {
434                if in_t {
435                    crate::push_general_ref(&mut text_buf, e);
436                }
437            }
438            Ok(Event::CData(ref e)) => {
439                if in_t {
440                    text_buf.push_str(e.as_ref());
441                }
442            }
443            Ok(Event::Eof) => break,
444            Err(e) => return Err(DocError::corrupt(format!("slide parse error: {e}"))),
445            _ => {}
446        }
447        buf.clear();
448    }
449    Ok(texts)
450}
451
452/// 从 slide rels XML 中找出所有 chart 引用的 Target 路径。
453fn find_chart_targets(rels_xml: &str) -> Vec<String> {
454    use quick_xml::events::Event;
455    use quick_xml::Reader;
456
457    let mut reader = Reader::from_str(rels_xml);
458    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
459    let mut buf = Vec::new();
460    let mut targets = Vec::new();
461    let chart_type = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
462
463    loop {
464        match reader.read_event_into(&mut buf) {
465            Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => {
466                if e.name().local_name().as_ref() == "Relationship" {
467                    let mut target = None;
468                    let mut type_val = None;
469                    for attr in e.attributes().filter_map(|a| a.ok()) {
470                        match attr.key.as_ref() {
471                            "Type" => type_val = Some(attr.value.to_string()),
472                            "Target" => target = Some(attr.value.to_string()),
473                            _ => {}
474                        }
475                    }
476                    if type_val.as_deref() == Some(chart_type) {
477                        if let Some(t) = target {
478                            targets.push(t);
479                        }
480                    }
481                }
482            }
483            Ok(Event::Eof) => break,
484            _ => {}
485        }
486        buf.clear();
487    }
488    targets
489}
490
491/// 将 rels 中相对于源文件的 Target 路径解析为 ZIP 中的绝对路径。
492///
493/// source_path: "ppt/slides/slide1.xml"
494/// target:     "../charts/chart1.xml"
495/// result:     "ppt/charts/chart1.xml"
496fn resolve_rels_target(source_path: &str, target: &str) -> String {
497    let source_dir = source_path
498        .rfind('/')
499        .map(|i| &source_path[..i])
500        .unwrap_or("");
501    let mut result = source_dir.to_string();
502    let mut remaining = target;
503    while remaining.starts_with("../") {
504        if let Some(idx) = result.rfind('/') {
505            result = result[..idx].to_string();
506        } else {
507            result.clear();
508        }
509        remaining = &remaining[3..];
510    }
511    if result.is_empty() {
512        remaining.to_string()
513    } else {
514        format!("{}/{}", result, remaining)
515    }
516}
517
518/// 从 chart XML 中提取文本。
519///
520/// 提取 `<a:t>`(富文本标题/标签)和 `<c:tx>` 内 `<c:v>`(纯文本数据标签)。
521/// 图表 title 和 data label 的文本通常在这两种形式中,而分类/序列数据值
522/// 不在 `<c:tx>` 内,不会被提取。
523fn extract_chart_texts(xml: &str) -> Result<Vec<String>> {
524    let mut reader = Reader::from_str(xml);
525    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
526    let mut buf = Vec::new();
527    let mut texts = Vec::new();
528    let mut in_t = false;
529    let mut in_tx = false;
530    let mut in_tx_v = false;
531    let mut text_buf = String::new();
532
533    loop {
534        match reader.read_event_into(&mut buf) {
535            Ok(Event::Start(ref e)) => {
536                let name = e.name().local_name().as_ref().to_string();
537                if name == crate::tags::C_TX {
538                    in_tx = true;
539                } else if name == crate::tags::C_V && in_tx {
540                    in_tx_v = true;
541                    text_buf.clear();
542                } else if name == crate::tags::A_T {
543                    in_t = true;
544                    text_buf.clear();
545                }
546            }
547            Ok(Event::End(ref e)) => {
548                let name = e.name().local_name().as_ref().to_string();
549                if name == crate::tags::C_TX {
550                    in_tx = false;
551                } else if name == crate::tags::C_V && in_tx_v {
552                    in_tx_v = false;
553                    let t = text_buf.trim().to_string();
554                    if !t.is_empty() {
555                        texts.push(t);
556                    }
557                    text_buf.clear();
558                } else if name == crate::tags::A_T {
559                    in_t = false;
560                    let t = text_buf.trim().to_string();
561                    if !t.is_empty() {
562                        texts.push(t);
563                    }
564                    text_buf.clear();
565                }
566            }
567            Ok(Event::Text(ref e)) => {
568                if in_t || in_tx_v {
569                    let raw = e.xml10_content();
570                    if let Ok(t) = quick_xml::escape::unescape(&raw) {
571                        text_buf.push_str(&t);
572                    }
573                }
574            }
575            Ok(Event::GeneralRef(ref e)) => {
576                if in_t || in_tx_v {
577                    crate::push_general_ref(&mut text_buf, e);
578                }
579            }
580            Ok(Event::CData(ref e)) => {
581                if in_t || in_tx_v {
582                    text_buf.push_str(e.as_ref());
583                }
584            }
585            Ok(Event::Eof) => break,
586            Err(_) => break,
587            _ => {}
588        }
589        buf.clear();
590    }
591    Ok(texts)
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_slide_list() {
600        let xml = concat!(
601            r#"<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" "#,
602            r#"xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
603            r#"  <p:sldIdLst>"#,
604            r#"    <p:sldId id="256" r:id="rId1"/>"#,
605            r#"    <p:sldId id="257" r:id="rId2"/>"#,
606            r#"  </p:sldIdLst>"#,
607            r#"</p:presentation>"#
608        );
609        let rids = parse_slide_list(xml).unwrap();
610        assert_eq!(rids, vec!["rId1", "rId2"]);
611    }
612
613    #[test]
614    fn test_slide_text() {
615        let xml = concat!(
616            r#"<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" "#,
617            r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">"#,
618            r#"  <p:spTree>"#,
619            r#"    <p:sp>"#,
620            r#"      <p:nvSpPr><p:cNvPr id="1"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>"#,
621            r#"      <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="100" cy="50"/></a:xfrm></p:spPr>"#,
622            r#"      <p:txBody>"#,
623            r#"        <a:p>"#,
624            r#"          <a:r>"#,
625            r#"            <a:t>Hello World</a:t>"#,
626            r#"          </a:r>"#,
627            r#"        </a:p>"#,
628            r#"      </p:txBody>"#,
629            r#"    </p:sp>"#,
630            r#"  </p:spTree>"#,
631            r#"</p:sld>"#
632        );
633        let (items, chart_pos) = extract_slide_items(xml).unwrap();
634        assert!(chart_pos.is_empty());
635        assert!(items.iter().any(|(_, _, t)| t == "Hello World"));
636    }
637}