1use std::borrow::Cow;
10use std::fs::File;
11use std::io::{BufReader, Read};
12use std::path::Path;
13
14use super::image::{Relationships, extension_from_filename, read_zip_part};
15use crate::security::{SecurityPolicy, SsrfGuard};
16use easydoc_core::{
17 DocError, DocumentBlock, DocumentEvent, DocumentImage, DocumentList, DocumentListItem,
18 DocumentTableCell, DocumentTableRow, DocumentTextRun, EventSink, Result,
19};
20use quick_xml::Reader as XmlReader;
21use quick_xml::events::Event;
22
23const W_P: &[u8] = b"w:p";
28const W_R: &[u8] = b"w:r";
29const W_T: &[u8] = b"w:t";
30const W_PPR: &[u8] = b"w:pPr";
31const W_PSTYLE: &[u8] = b"w:pStyle";
32const W_RPR: &[u8] = b"w:rPr";
33const W_B: &[u8] = b"w:b";
34const W_I: &[u8] = b"w:i";
35const W_STRIKE: &[u8] = b"w:strike";
36const W_TBL: &[u8] = b"w:tbl";
37const W_TR: &[u8] = b"w:tr";
38const W_TC: &[u8] = b"w:tc";
39const W_BR: &[u8] = b"w:br";
40const W_DRAWING: &[u8] = b"w:drawing";
41const W_TCPR: &[u8] = b"w:tcPr";
42const W_GRIDSPAN: &[u8] = b"w:gridSpan";
43const W_VMERGE: &[u8] = b"w:vMerge";
44
45const A_BLIP: &[u8] = b"a:blip";
46const WP_DOC_PR: &[u8] = b"wp:docPr";
47const R_EMBED: &[u8] = b"r:embed";
48
49const W_VAL: &[u8] = b"w:val";
50const W_TYPE: &[u8] = b"w:type";
51
52const W_NUMPR: &[u8] = b"w:numPr";
54const W_NUMID: &[u8] = b"w:numId";
55const W_ILVL: &[u8] = b"w:ilvl";
56
57const W_HYPERLINK: &[u8] = b"w:hyperlink";
59const R_ID: &[u8] = b"r:id";
60
61const M_OMATH: &[u8] = b"m:oMath";
63const M_OMATHPARA: &[u8] = b"m:oMathPara";
64
65#[derive(Debug)]
71enum ParseState {
72 Document,
74 Paragraph {
76 runs: Vec<DocumentTextRun>,
78 heading_level: Option<u8>,
80 in_ppr: bool,
82 in_run: bool,
84 in_rpr: bool,
86 run_bold: bool,
88 run_italic: bool,
90 run_strike: bool,
92 text_buf: String,
94 in_text: bool,
96 preserve_space: bool,
98 has_num_pr: bool,
100 num_id: Option<u32>,
102 ilvl: Option<u8>,
104 in_hyperlink: bool,
106 hyperlink_rid: Option<String>,
108 },
109 Table {
111 rows: Vec<DocumentTableRow>,
113 current_row: Option<TableRowBuilder>,
115 },
116 Drawing {
118 pending_rid: Option<String>,
120 pending_alt: Option<String>,
122 },
123}
124
125#[derive(Debug)]
127struct TableRowBuilder {
128 cells: Vec<TableCellBuilder>,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum VMerge {
134 Restart,
136 Continue,
138}
139
140#[derive(Debug)]
142struct TableCellBuilder {
143 text: String,
144 column_span: u32,
146 row_span: u32,
148 v_merge: Option<VMerge>,
150 in_tcpr: bool,
152 blocks: Vec<DocumentBlock>,
154}
155
156trait ParseSink {
165 fn on_event(&mut self, event: &DocumentEvent) -> Result<()>;
167 fn push_block(&mut self, block: DocumentBlock);
169 fn on_complete(&mut self) {}
171}
172
173struct EventSinkAdapter<'a>(&'a mut dyn EventSink);
178
179impl ParseSink for EventSinkAdapter<'_> {
180 fn on_event(&mut self, event: &DocumentEvent) -> Result<()> {
181 self.0.on_event(event)
182 }
183
184 fn push_block(&mut self, _block: DocumentBlock) {
185 }
188
189 fn on_complete(&mut self) {
190 self.0.on_complete();
191 }
192}
193
194struct BlockCollector(Vec<DocumentBlock>);
198
199impl ParseSink for BlockCollector {
200 fn on_event(&mut self, event: &DocumentEvent) -> Result<()> {
201 match event {
202 DocumentEvent::Heading { level, runs } => {
203 self.0.push(DocumentBlock::Heading {
204 level: *level,
205 runs: runs.clone(),
206 });
207 }
208 DocumentEvent::Paragraph(runs) => {
209 self.0.push(DocumentBlock::Paragraph(runs.clone()));
210 }
211 DocumentEvent::Table(table) => {
212 self.0.push(DocumentBlock::Table(table.clone()));
213 }
214 DocumentEvent::List(list) => {
215 self.0.push(DocumentBlock::List(list.clone()));
216 }
217 DocumentEvent::Image(image) => {
218 self.0.push(DocumentBlock::Image(image.clone()));
219 }
220 DocumentEvent::PageBreak => {
221 self.0.push(DocumentBlock::PageBreak);
222 }
223 DocumentEvent::ColumnBreak => {
224 self.0.push(DocumentBlock::ColumnBreak);
225 }
226 DocumentEvent::CodeBlock { language, code } => {
227 self.0.push(DocumentBlock::CodeBlock {
228 language: language.clone(),
229 code: code.clone(),
230 });
231 }
232 DocumentEvent::Section { section_type } => {
233 self.0.push(DocumentBlock::Section {
234 blocks: Vec::new(),
235 section_type: section_type.clone(),
236 });
237 }
238 DocumentEvent::DocumentStart | DocumentEvent::DocumentEnd => {}
239 }
240 Ok(())
241 }
242
243 fn push_block(&mut self, block: DocumentBlock) {
244 self.0.push(block);
245 }
246}
247
248fn flush_paragraph_runs(sink: &mut dyn ParseSink, stack: &mut [ParseState]) -> Result<()> {
252 if let Some(ParseState::Paragraph { runs, .. }) = stack.last_mut()
253 && !runs.is_empty()
254 {
255 let taken = std::mem::take(runs);
256 sink.on_event(&DocumentEvent::Paragraph(taken))?;
257 }
258 Ok(())
259}
260
261fn flush_list(
276 sink: &mut dyn ParseSink,
277 list_items: &mut Vec<(DocumentListItem, u8)>,
278 first_num_id: &mut Option<u32>,
279 first_ilvl: &mut u8,
280 numbering: Option<&super::numbering::Numbering>,
281 relationships: Option<&Relationships>,
282 ssrf: &SsrfGuard,
283) -> Result<()> {
284 if !list_items.is_empty() {
285 resolve_hyperlinks_in_flat_items(list_items, relationships, ssrf);
287
288 let (ordered, start_number) = if let (Some(num_id), Some(num)) = (*first_num_id, numbering)
290 {
291 match num.lookup(num_id, *first_ilvl) {
292 Some(level) => {
293 let start = if level.ordered { level.start } else { None };
294 (level.ordered, start)
295 }
296 None => (false, None),
297 }
298 } else {
299 (false, None)
300 };
301
302 let flat = std::mem::take(list_items);
303 let items = build_nested_items(flat);
304 sink.push_block(DocumentBlock::List(DocumentList {
305 ordered,
306 start_number,
307 items,
308 }));
309 *first_num_id = None;
310 *first_ilvl = 0;
311 }
312 Ok(())
313}
314
315fn build_nested_items(flat: Vec<(DocumentListItem, u8)>) -> Vec<DocumentListItem> {
324 let mut items: Vec<DocumentListItem> = Vec::new();
325
326 for (new_item, ilvl) in flat {
327 if ilvl == 0 {
328 items.push(new_item);
329 } else {
330 if let Some(parent) = items.last_mut() {
332 attach_to_nested(parent, new_item, ilvl);
333 } else {
334 items.push(new_item);
336 }
337 }
338 }
339
340 items
341}
342
343fn attach_to_nested(parent: &mut DocumentListItem, new_item: DocumentListItem, ilvl: u8) {
349 if ilvl == 1 {
350 let nested = parent
352 .nested
353 .get_or_insert_with(|| Box::new(DocumentList::default()));
354 nested.items.push(new_item);
355 } else {
356 let nested = parent
358 .nested
359 .get_or_insert_with(|| Box::new(DocumentList::default()));
360 if let Some(last) = nested.items.last_mut() {
361 attach_to_nested(last, new_item, ilvl - 1);
362 } else {
363 nested.items.push(new_item);
366 }
367 }
368}
369
370fn resolve_hyperlinks_in_items(
372 items: &mut [DocumentListItem],
373 relationships: Option<&Relationships>,
374 ssrf: &SsrfGuard,
375) {
376 let Some(rels) = relationships else { return };
377 for item in items.iter_mut() {
378 resolve_hyperlinks_in_blocks(&mut item.blocks, rels, ssrf);
379 if let Some(nested) = item.nested.as_mut() {
381 resolve_hyperlinks_in_items(&mut nested.items, Some(rels), ssrf);
382 }
383 }
384}
385
386fn resolve_hyperlinks_in_flat_items(
388 items: &mut [(DocumentListItem, u8)],
389 relationships: Option<&Relationships>,
390 ssrf: &SsrfGuard,
391) {
392 let Some(rels) = relationships else { return };
393 for (item, _ilvl) in items.iter_mut() {
394 resolve_hyperlinks_in_blocks(&mut item.blocks, rels, ssrf);
395 }
396}
397
398fn resolve_hyperlinks_in_blocks(
400 blocks: &mut [DocumentBlock],
401 rels: &Relationships,
402 ssrf: &SsrfGuard,
403) {
404 for block in blocks {
405 match block {
406 DocumentBlock::Paragraph(runs) | DocumentBlock::Heading { runs, .. } => {
407 resolve_hyperlinks_in_runs(runs, rels, ssrf);
408 }
409 DocumentBlock::List(list) => {
410 resolve_hyperlinks_in_items(&mut list.items, Some(rels), ssrf);
411 }
412 DocumentBlock::Table(table) => {
413 for row in &mut table.rows {
414 for cell in &mut row.cells {
415 resolve_hyperlinks_in_blocks(&mut cell.blocks, rels, ssrf);
416 }
417 }
418 }
419 _ => {}
420 }
421 }
422}
423
424fn resolve_hyperlinks_in_runs(
431 runs: &mut [DocumentTextRun],
432 rels: &Relationships,
433 ssrf: &SsrfGuard,
434) {
435 for run in runs.iter_mut() {
436 if let Some(rid) = run.hyperlink.take() {
437 match rels.resolve_hyperlink(&rid) {
438 Some(resolved_url) => {
439 if ssrf.check_url(resolved_url).is_ok() {
442 run.hyperlink = Some(resolved_url.to_owned());
443 }
444 }
446 None => {
447 run.hyperlink = Some(rid);
449 }
450 }
451 }
452 }
453}
454
455fn extract_rid(tag: &quick_xml::events::BytesStart) -> Option<String> {
457 for attr in tag.attributes().flatten() {
458 if attr.key.as_ref() == R_ID {
459 return attr
460 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
461 .ok()
462 .map(std::borrow::Cow::into_owned);
463 }
464 }
465 None
466}
467
468pub struct DocxSaxReader<R: Read> {
493 reader: XmlReader<BufReader<R>>,
494 archive: Option<zip::ZipArchive<File>>,
496 relationships: Option<Relationships>,
498 numbering: Option<super::numbering::Numbering>,
500 security: SecurityPolicy,
502}
503
504impl DocxSaxReader<std::io::Cursor<Vec<u8>>> {
505 pub fn from_path(path: &Path) -> Result<Self> {
516 Self::from_path_with_security(path, SecurityPolicy::new())
517 }
518
519 pub fn from_path_with_security(path: &Path, security: SecurityPolicy) -> Result<Self> {
528 let file = File::open(path)?;
529 let mut archive = zip::ZipArchive::new(file)?;
530
531 security
533 .limits
534 .validate_archive(&mut archive)
535 .map_err(|msg| DocError::Format(format!("security: {msg}")))?;
536
537 let entry_name = if archive.index_for_name("word/document.xml").is_some() {
539 "word/document.xml".to_owned()
540 } else {
541 find_word_document_xml(&mut archive)?
542 };
543
544 let relationships = if archive
546 .index_for_name("word/_rels/document.xml.rels")
547 .is_some()
548 {
549 let rels_bytes = {
550 let mut entry = archive.by_name("word/_rels/document.xml.rels")?;
551 let mut buf = Vec::new();
552 std::io::Read::read_to_end(&mut entry, &mut buf)?;
553 buf
554 };
555 let rels_xml = String::from_utf8(rels_bytes)
556 .map_err(|e| DocError::Format(format!("rels XML not valid UTF-8: {e}")))?;
557 Some(Relationships::parse(&rels_xml)?)
558 } else {
559 None
560 };
561
562 let numbering = if archive.index_for_name("word/numbering.xml").is_some() {
564 let num_bytes = {
565 let mut entry = archive.by_name("word/numbering.xml")?;
566 let mut buf = Vec::new();
567 std::io::Read::read_to_end(&mut entry, &mut buf)?;
568 buf
569 };
570 match String::from_utf8(num_bytes) {
571 Ok(xml) => super::numbering::Numbering::parse(&xml).ok(),
572 Err(_) => None,
573 }
574 } else {
575 None
576 };
577
578 let xml_bytes = {
580 let mut entry = archive.by_name(&entry_name)?;
581 let mut buf = Vec::new();
582 std::io::Read::read_to_end(&mut entry, &mut buf)?;
583 buf
584 };
585
586 let buf_reader = BufReader::new(std::io::Cursor::new(xml_bytes));
587 let mut reader = XmlReader::from_reader(buf_reader);
588 reader.config_mut().trim_text(false);
589
590 Ok(Self {
591 reader,
592 archive: Some(archive),
593 relationships,
594 numbering,
595 security,
596 })
597 }
598}
599
600impl<R: Read> DocxSaxReader<R> {
601 pub fn from_reader(source: R) -> Self {
605 let buf_reader = BufReader::new(source);
606 let mut reader = XmlReader::from_reader(buf_reader);
607 reader.config_mut().trim_text(false);
608 Self {
609 reader,
610 archive: None,
611 relationships: None,
612 numbering: None,
613 security: SecurityPolicy::new(),
614 }
615 }
616
617 pub fn read_events(&mut self, sink: &mut dyn EventSink) -> Result<()> {
628 let mut adapter = EventSinkAdapter(sink);
629 self.parse_with_sink(&mut adapter)
630 }
631
632 pub fn read_blocks(&mut self) -> Result<Vec<DocumentBlock>> {
644 let mut collector = BlockCollector(Vec::new());
645 self.parse_with_sink(&mut collector)?;
646 Ok(collector.0)
647 }
648
649 fn parse_with_sink(&mut self, sink: &mut dyn ParseSink) -> Result<()> {
652 sink.on_event(&DocumentEvent::DocumentStart)?;
653
654 let mut state_stack: Vec<ParseState> = vec![ParseState::Document];
655 let mut buf = Vec::new();
656
657 let mut list_items: Vec<(DocumentListItem, u8)> = Vec::new();
662 let mut first_list_num_id: Option<u32> = None;
665 let mut first_list_ilvl: u8 = 0;
666
667 let mut in_math = false;
669 let mut math_is_para = false;
670 let mut math_depth: u32 = 0;
671 let mut math_xml_buf = String::new();
672
673 loop {
674 let event = self
675 .reader
676 .read_event_into(&mut buf)
677 .map_err(|e| DocError::Format(format!("XML parse error: {e}")))?;
678
679 if in_math {
683 match &event {
684 Event::Start(start) => {
685 let name = start.name();
686 let name_bytes = name.as_ref();
687 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
688 math_depth += 1;
689 }
690 math_xml_buf.push('<');
691 math_xml_buf.push_str(std::str::from_utf8(start.as_ref()).unwrap_or(""));
692 math_xml_buf.push('>');
693 }
694 Event::End(end) => {
695 let name = end.name();
696 let name_bytes = name.as_ref();
697
698 math_xml_buf.push_str("</");
700 math_xml_buf.push_str(std::str::from_utf8(name_bytes).unwrap_or(""));
701 math_xml_buf.push('>');
702
703 let is_closing_root = if math_is_para {
706 name_bytes == M_OMATHPARA
707 } else {
708 name_bytes == M_OMATH
709 };
710
711 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
712 math_depth = math_depth.saturating_sub(1);
713 }
714
715 if is_closing_root && math_depth == 0 {
716 sink.push_block(DocumentBlock::Math {
717 omml: Some(std::mem::take(&mut math_xml_buf)),
718 latex: None,
719 display: math_is_para,
720 });
721 in_math = false;
722 }
723 }
724 Event::Empty(empty) => {
725 math_xml_buf.push('<');
726 math_xml_buf.push_str(std::str::from_utf8(empty.as_ref()).unwrap_or(""));
727 math_xml_buf.push_str("/>");
728 }
729 Event::Text(text) => {
730 math_xml_buf.push_str(std::str::from_utf8(text.as_ref()).unwrap_or(""));
731 }
732 _ => {}
733 }
734 buf.clear();
735 continue;
736 }
737
738 match event {
740 Event::Eof => break,
741 Event::Start(ref start) => {
742 let name = start.name();
743 let name_bytes = name.as_ref();
744 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
745 flush_paragraph_runs(sink, &mut state_stack)?;
748 in_math = true;
749 math_is_para = name_bytes == M_OMATHPARA;
750 math_depth = 1;
751 math_xml_buf.clear();
752 math_xml_buf.push('<');
753 math_xml_buf.push_str(std::str::from_utf8(start.as_ref()).unwrap_or(""));
754 math_xml_buf.push('>');
755 } else {
756 handle_start(start, &mut state_stack)?;
757 }
758 }
759 Event::Empty(ref empty) => {
760 let name = empty.name();
761 let name_bytes = name.as_ref();
762 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
763 flush_paragraph_runs(sink, &mut state_stack)?;
765 let display = name_bytes == M_OMATHPARA;
766 let mut xml = String::from("<");
767 xml.push_str(std::str::from_utf8(empty.as_ref()).unwrap_or(""));
768 xml.push_str("/>");
769 sink.push_block(DocumentBlock::Math {
770 omml: Some(xml),
771 latex: None,
772 display,
773 });
774 } else {
775 handle_empty(empty, sink, &mut state_stack)?;
776 }
777 }
778 Event::Text(ref text) => {
779 handle_text(text, &mut state_stack)?;
780 }
781 Event::End(ref end) => {
782 handle_end(
783 end,
784 sink,
785 &mut state_stack,
786 &mut ParseContext {
787 archive: self.archive.as_mut(),
788 relationships: self.relationships.as_ref(),
789 numbering: self.numbering.as_ref(),
790 list_items: &mut list_items,
791 first_list_num_id: &mut first_list_num_id,
792 first_list_ilvl: &mut first_list_ilvl,
793 ssrf: &self.security.ssrf,
794 },
795 )?;
796 }
797 _ => {}
798 }
799
800 buf.clear();
801 }
802
803 flush_list(
805 sink,
806 &mut list_items,
807 &mut first_list_num_id,
808 &mut first_list_ilvl,
809 self.numbering.as_ref(),
810 self.relationships.as_ref(),
811 &self.security.ssrf,
812 )?;
813
814 sink.on_event(&DocumentEvent::DocumentEnd)?;
815 sink.on_complete();
816 Ok(())
817 }
818}
819
820fn handle_start(start: &quick_xml::events::BytesStart, stack: &mut Vec<ParseState>) -> Result<()> {
825 let name = start.name();
826 let local = name.as_ref();
827
828 match local {
829 W_P => {
830 if !inside_table(stack) {
834 stack.push(ParseState::Paragraph {
835 runs: Vec::new(),
836 heading_level: None,
837 in_ppr: false,
838 in_run: false,
839 in_rpr: false,
840 run_bold: false,
841 run_italic: false,
842 run_strike: false,
843 text_buf: String::new(),
844 in_text: false,
845 preserve_space: false,
846 has_num_pr: false,
847 num_id: None,
848 ilvl: None,
849 in_hyperlink: false,
850 hyperlink_rid: None,
851 });
852 }
853 }
854 W_TBL => {
855 stack.push(ParseState::Table {
856 rows: Vec::new(),
857 current_row: None,
858 });
859 }
860 W_DRAWING => {
861 stack.push(ParseState::Drawing {
862 pending_rid: None,
863 pending_alt: None,
864 });
865 }
866 W_PPR => {
867 if let Some(ParseState::Paragraph { in_ppr, .. }) = stack.last_mut() {
868 *in_ppr = true;
869 }
870 }
871 W_NUMPR => {
872 if let Some(ParseState::Paragraph { has_num_pr, .. }) = stack.last_mut() {
874 *has_num_pr = true;
875 }
876 }
877 W_NUMID => {
878 if let Some(ParseState::Paragraph { num_id, .. }) = stack.last_mut() {
879 *num_id = extract_val(start).and_then(|v| v.parse::<u32>().ok());
880 }
881 }
882 W_ILVL => {
883 if let Some(ParseState::Paragraph { ilvl, .. }) = stack.last_mut() {
884 *ilvl = extract_val(start).and_then(|v| v.parse::<u8>().ok());
885 }
886 }
887 W_HYPERLINK => {
888 if let Some(ParseState::Paragraph {
891 in_hyperlink,
892 hyperlink_rid,
893 ..
894 }) = stack.last_mut()
895 {
896 *in_hyperlink = true;
897 *hyperlink_rid = extract_rid(start);
898 }
899 }
900 W_PSTYLE => {
901 if let Some(ParseState::Paragraph { heading_level, .. }) = stack.last_mut() {
902 *heading_level = extract_val(start).and_then(|v| parse_heading_level(&v));
903 }
904 }
905 W_R => {
906 if let Some(ParseState::Paragraph {
907 in_run,
908 run_bold,
909 run_italic,
910 run_strike,
911 ..
912 }) = stack.last_mut()
913 {
914 *in_run = true;
915 *run_bold = false;
916 *run_italic = false;
917 *run_strike = false;
918 }
919 }
920 W_RPR => {
921 if let Some(ParseState::Paragraph { in_rpr, .. }) = stack.last_mut() {
922 *in_rpr = true;
923 }
924 }
925 W_B => {
926 if let Some(ParseState::Paragraph {
927 in_rpr, run_bold, ..
928 }) = stack.last_mut()
929 && *in_rpr
930 {
931 *run_bold = extract_bool_attr(start).unwrap_or(true);
932 }
933 }
934 W_I => {
935 if let Some(ParseState::Paragraph {
936 in_rpr, run_italic, ..
937 }) = stack.last_mut()
938 && *in_rpr
939 {
940 *run_italic = extract_bool_attr(start).unwrap_or(true);
941 }
942 }
943 W_STRIKE => {
944 if let Some(ParseState::Paragraph {
945 in_rpr, run_strike, ..
946 }) = stack.last_mut()
947 && *in_rpr
948 {
949 *run_strike = extract_bool_attr(start).unwrap_or(true);
950 }
951 }
952 W_T => {
953 if let Some(ParseState::Paragraph {
954 in_text,
955 preserve_space,
956 ..
957 }) = stack.last_mut()
958 {
959 *in_text = true;
960 *preserve_space = has_preserve_space(start);
961 }
962 }
963 W_TR => {
964 if let Some(ParseState::Table { current_row, .. }) = stack.last_mut() {
965 *current_row = Some(TableRowBuilder { cells: Vec::new() });
966 }
967 }
968 W_TC => {
969 if let Some(ParseState::Table {
970 current_row: Some(row),
971 ..
972 }) = stack.last_mut()
973 {
974 row.cells.push(TableCellBuilder {
975 text: String::new(),
976 column_span: 1,
977 row_span: 1,
978 v_merge: None,
979 in_tcpr: false,
980 blocks: Vec::new(),
981 });
982 }
983 }
984 W_TCPR => {
985 if let Some(ParseState::Table {
986 current_row: Some(row),
987 ..
988 }) = stack.last_mut()
989 && let Some(cell) = row.cells.last_mut()
990 {
991 cell.in_tcpr = true;
992 }
993 }
994 _ => {}
995 }
996
997 Ok(())
998}
999
1000fn handle_empty(
1001 empty: &quick_xml::events::BytesStart,
1002 sink: &mut dyn ParseSink,
1003 stack: &mut [ParseState],
1004) -> Result<()> {
1005 let name = empty.name();
1006 let local = name.as_ref();
1007
1008 match local {
1009 W_BR => {
1010 if br_is_page_break(empty) {
1011 sink.on_event(&DocumentEvent::PageBreak)?;
1012 }
1013 }
1014 W_NUMID => {
1015 if let Some(ParseState::Paragraph { num_id, .. }) = stack.last_mut() {
1016 *num_id = extract_val(empty).and_then(|v| v.parse::<u32>().ok());
1017 }
1018 }
1019 W_ILVL => {
1020 if let Some(ParseState::Paragraph { ilvl, .. }) = stack.last_mut() {
1021 *ilvl = extract_val(empty).and_then(|v| v.parse::<u8>().ok());
1022 }
1023 }
1024 W_PSTYLE => {
1025 if let Some(ParseState::Paragraph { heading_level, .. }) = stack.last_mut() {
1026 *heading_level = extract_val(empty).and_then(|v| parse_heading_level(&v));
1027 }
1028 }
1029 W_B => {
1030 if let Some(ParseState::Paragraph {
1031 in_rpr, run_bold, ..
1032 }) = stack.last_mut()
1033 && *in_rpr
1034 {
1035 *run_bold = extract_bool_attr(empty).unwrap_or(true);
1036 }
1037 }
1038 W_I => {
1039 if let Some(ParseState::Paragraph {
1040 in_rpr, run_italic, ..
1041 }) = stack.last_mut()
1042 && *in_rpr
1043 {
1044 *run_italic = extract_bool_attr(empty).unwrap_or(true);
1045 }
1046 }
1047 W_STRIKE => {
1048 if let Some(ParseState::Paragraph {
1049 in_rpr, run_strike, ..
1050 }) = stack.last_mut()
1051 && *in_rpr
1052 {
1053 *run_strike = extract_bool_attr(empty).unwrap_or(true);
1054 }
1055 }
1056 A_BLIP => {
1057 if let Some(ParseState::Drawing { pending_rid, .. }) = stack.last_mut() {
1058 for attr in empty.attributes().flatten() {
1059 if attr.key.as_ref() == R_EMBED {
1060 *pending_rid = attr
1061 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1062 .ok()
1063 .map(Cow::into_owned);
1064 break;
1065 }
1066 }
1067 }
1068 }
1069 W_GRIDSPAN => {
1070 if let Some(ParseState::Table {
1072 current_row: Some(row),
1073 ..
1074 }) = stack.last_mut()
1075 && let Some(cell) = row.cells.last_mut()
1076 && cell.in_tcpr
1077 && let Some(val) = extract_val(empty)
1078 && let Ok(n) = val.parse::<u32>()
1079 && n > 1
1080 {
1081 cell.column_span = n;
1082 }
1083 }
1084 W_VMERGE => {
1085 if let Some(ParseState::Table {
1089 current_row: Some(row),
1090 ..
1091 }) = stack.last_mut()
1092 && let Some(cell) = row.cells.last_mut()
1093 && cell.in_tcpr
1094 {
1095 if let Some("restart") = extract_val(empty).as_deref() {
1096 cell.v_merge = Some(VMerge::Restart);
1097 cell.row_span = 1;
1098 } else {
1099 cell.v_merge = Some(VMerge::Continue);
1101 cell.row_span = 0;
1102 }
1103 }
1104 }
1105 WP_DOC_PR => {
1106 if let Some(ParseState::Drawing { pending_alt, .. }) = stack.last_mut() {
1107 for attr in empty.attributes().flatten() {
1108 if attr.key.as_ref() == b"descr" {
1109 let val = attr
1110 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1111 .ok()
1112 .map(Cow::into_owned);
1113 if val.as_deref().is_some_and(|s| !s.is_empty()) {
1114 *pending_alt = val;
1115 }
1116 break;
1117 }
1118 }
1119 }
1120 }
1121 _ => {}
1122 }
1123
1124 Ok(())
1125}
1126
1127fn handle_text(text: &quick_xml::events::BytesText, stack: &mut [ParseState]) -> Result<()> {
1128 let decoded = std::str::from_utf8(text.as_ref()).unwrap_or("").to_owned();
1131 if let Some(state) = stack.last_mut() {
1132 match state {
1133 ParseState::Paragraph {
1134 in_text: true,
1135 text_buf,
1136 ..
1137 } => {
1138 text_buf.push_str(&decoded);
1139 }
1140 ParseState::Table {
1141 current_row: Some(row),
1142 ..
1143 } => {
1144 if let Some(cell) = row.cells.last_mut() {
1145 cell.text.push_str(&decoded);
1146 }
1147 }
1148 _ => {}
1149 }
1150 }
1151 Ok(())
1152}
1153
1154struct ParseContext<'a> {
1156 archive: Option<&'a mut zip::ZipArchive<File>>,
1157 relationships: Option<&'a Relationships>,
1158 numbering: Option<&'a super::numbering::Numbering>,
1159 list_items: &'a mut Vec<(DocumentListItem, u8)>,
1162 first_list_num_id: &'a mut Option<u32>,
1163 first_list_ilvl: &'a mut u8,
1164 ssrf: &'a SsrfGuard,
1166}
1167
1168fn handle_end(
1169 end: &quick_xml::events::BytesEnd,
1170 sink: &mut dyn ParseSink,
1171 stack: &mut Vec<ParseState>,
1172 ctx: &mut ParseContext<'_>,
1173) -> Result<()> {
1174 let name = end.name();
1175 let local = name.as_ref();
1176
1177 match local {
1178 W_P => {
1179 if matches!(stack.last(), Some(ParseState::Paragraph { .. }))
1182 && let Some(ParseState::Paragraph {
1183 runs,
1184 heading_level,
1185 has_num_pr,
1186 num_id,
1187 ilvl,
1188 ..
1189 }) = stack.pop()
1190 {
1191 if let Some(level) = heading_level {
1192 flush_list(
1194 sink,
1195 ctx.list_items,
1196 ctx.first_list_num_id,
1197 ctx.first_list_ilvl,
1198 ctx.numbering,
1199 ctx.relationships,
1200 ctx.ssrf,
1201 )?;
1202 sink.on_event(&DocumentEvent::Heading { level, runs })?;
1203 } else if has_num_pr {
1204 let item_ilvl = ilvl.unwrap_or(0);
1207 if ctx.list_items.is_empty() {
1208 *ctx.first_list_num_id = num_id;
1209 *ctx.first_list_ilvl = item_ilvl;
1210 }
1211 if !runs.is_empty() {
1212 ctx.list_items.push((
1213 DocumentListItem {
1214 blocks: vec![DocumentBlock::Paragraph(runs)],
1215 nested: None,
1216 },
1217 item_ilvl,
1218 ));
1219 }
1220 } else {
1221 flush_list(
1223 sink,
1224 ctx.list_items,
1225 ctx.first_list_num_id,
1226 ctx.first_list_ilvl,
1227 ctx.numbering,
1228 ctx.relationships,
1229 ctx.ssrf,
1230 )?;
1231 if !runs.is_empty() {
1232 sink.on_event(&DocumentEvent::Paragraph(runs))?;
1234 }
1235 }
1236 }
1237 }
1238 W_R => {
1239 if let Some(ParseState::Paragraph {
1240 in_run,
1241 text_buf,
1242 run_bold,
1243 run_italic,
1244 run_strike,
1245 runs,
1246 in_hyperlink,
1247 hyperlink_rid,
1248 ..
1249 }) = stack.last_mut()
1250 {
1251 if *in_run && !text_buf.is_empty() {
1252 let hyperlink = if *in_hyperlink {
1253 hyperlink_rid.as_ref().map(|rid| {
1254 match ctx
1257 .relationships
1258 .and_then(|rels| rels.resolve_hyperlink(rid))
1259 {
1260 Some(resolved_url) => {
1261 if ctx.ssrf.check_url(resolved_url).is_ok() {
1263 resolved_url.to_owned()
1264 } else {
1265 rid.clone()
1267 }
1268 }
1269 None => rid.clone(),
1270 }
1271 })
1272 } else {
1273 None
1274 };
1275 runs.push(DocumentTextRun {
1276 text: std::mem::take(text_buf),
1277 bold: *run_bold,
1278 italic: *run_italic,
1279 strikethrough: *run_strike,
1280 hyperlink,
1281 });
1282 }
1283 *in_run = false;
1284 *run_bold = false;
1285 *run_italic = false;
1286 *run_strike = false;
1287 }
1288 }
1289 W_HYPERLINK => {
1290 if let Some(ParseState::Paragraph {
1293 in_hyperlink,
1294 hyperlink_rid,
1295 ..
1296 }) = stack.last_mut()
1297 {
1298 *in_hyperlink = false;
1299 *hyperlink_rid = None;
1300 }
1301 }
1302 W_T => {
1303 if let Some(ParseState::Paragraph { in_text, .. }) = stack.last_mut() {
1304 *in_text = false;
1305 }
1306 }
1307 W_PPR => {
1308 if let Some(ParseState::Paragraph { in_ppr, .. }) = stack.last_mut() {
1309 *in_ppr = false;
1310 }
1311 }
1312 W_RPR => {
1313 if let Some(ParseState::Paragraph { in_rpr, .. }) = stack.last_mut() {
1314 *in_rpr = false;
1315 }
1316 }
1317 W_TCPR => {
1318 if let Some(ParseState::Table {
1319 current_row: Some(row),
1320 ..
1321 }) = stack.last_mut()
1322 && let Some(cell) = row.cells.last_mut()
1323 {
1324 cell.in_tcpr = false;
1325 }
1326 }
1327 W_TBL => {
1328 if let Some(ParseState::Table { rows, .. }) = stack.pop() {
1329 let table = easydoc_core::DocumentTable { rows };
1330 if inside_table(stack) {
1331 if let Some(ParseState::Table {
1333 current_row: Some(row),
1334 ..
1335 }) = stack.last_mut()
1336 && let Some(cell) = row.cells.last_mut()
1337 {
1338 cell.blocks.push(DocumentBlock::Table(table));
1339 }
1340 } else {
1341 flush_list(
1343 sink,
1344 ctx.list_items,
1345 ctx.first_list_num_id,
1346 ctx.first_list_ilvl,
1347 ctx.numbering,
1348 ctx.relationships,
1349 ctx.ssrf,
1350 )?;
1351 sink.on_event(&DocumentEvent::Table(table))?;
1352 }
1353 }
1354 }
1355 W_TR => {
1356 if let Some(ParseState::Table {
1357 current_row, rows, ..
1358 }) = stack.last_mut()
1359 && let Some(row_builder) = current_row.take()
1360 {
1361 let cells = row_builder
1362 .cells
1363 .into_iter()
1364 .map(|c| {
1365 let mut blocks = Vec::new();
1366 let trimmed = c.text.trim().to_owned();
1367 if !trimmed.is_empty() {
1368 blocks.push(easydoc_core::DocumentBlock::Paragraph(vec![
1369 DocumentTextRun {
1370 text: trimmed,
1371 ..DocumentTextRun::default()
1372 },
1373 ]));
1374 }
1375 blocks.extend(c.blocks);
1376 DocumentTableCell {
1377 blocks,
1378 column_span: c.column_span,
1379 row_span: c.row_span,
1380 }
1381 })
1382 .collect();
1383 rows.push(DocumentTableRow {
1384 cells,
1385 is_header: false,
1386 });
1387 }
1388 }
1389 W_DRAWING => {
1390 if let Some(ParseState::Drawing {
1392 pending_rid,
1393 pending_alt,
1394 }) = stack.pop()
1395 {
1396 let (data, extension) = if let (Some(rid), Some(arch), Some(rels)) = (
1397 pending_rid.as_ref(),
1398 ctx.archive.as_deref_mut(),
1399 ctx.relationships,
1400 ) {
1401 if let Some(part_path) = rels.resolve(rid) {
1402 match read_zip_part(arch, part_path) {
1403 Ok(bytes) => (Some(bytes), extension_from_filename(part_path)),
1404 Err(_) => (None, None),
1405 }
1406 } else {
1407 (None, None)
1408 }
1409 } else {
1410 (None, None)
1411 };
1412
1413 let alt_text = pending_alt.or_else(|| Some("[image]".to_owned()));
1414
1415 sink.on_event(&DocumentEvent::Image(DocumentImage {
1416 alt_text,
1417 data,
1418 extension,
1419 }))?;
1420 }
1421 }
1422 _ => {}
1423 }
1424
1425 Ok(())
1426}
1427
1428fn inside_table(stack: &[ParseState]) -> bool {
1435 stack.iter().any(|s| matches!(s, ParseState::Table { .. }))
1436}
1437
1438fn extract_val(tag: &quick_xml::events::BytesStart) -> Option<String> {
1440 for attr in tag.attributes().flatten() {
1441 if attr.key.as_ref() == W_VAL {
1442 return attr
1443 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1444 .ok()
1445 .map(std::borrow::Cow::into_owned);
1446 }
1447 }
1448 None
1449}
1450
1451fn extract_bool_attr(tag: &quick_xml::events::BytesStart) -> Option<bool> {
1454 match extract_val(tag) {
1455 Some(v) => {
1456 let lower = v.to_lowercase();
1457 if lower == "false" || lower == "0" {
1458 Some(false)
1459 } else {
1460 Some(true)
1461 }
1462 }
1463 None => Some(true),
1464 }
1465}
1466
1467fn has_preserve_space(tag: &quick_xml::events::BytesStart) -> bool {
1469 for attr in tag.attributes().flatten() {
1470 if attr.key.as_ref() == b"xml:space" {
1471 return attr
1472 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1473 .ok()
1474 .is_some_and(|v| v.as_ref() == "preserve");
1475 }
1476 }
1477 false
1478}
1479
1480fn parse_heading_level(style: &str) -> Option<u8> {
1482 let trimmed = style.trim();
1483 let lower = trimmed.to_lowercase();
1485 let digits = lower
1486 .strip_prefix("heading")
1487 .or_else(|| lower.strip_prefix("heading "));
1488 digits
1489 .and_then(|d| d.trim().parse::<u8>().ok())
1490 .filter(|&l| (1..=6).contains(&l))
1491}
1492
1493fn br_is_page_break(tag: &quick_xml::events::BytesStart) -> bool {
1495 for attr in tag.attributes().flatten() {
1496 if attr.key.as_ref() == W_TYPE {
1497 return attr
1498 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1499 .ok()
1500 .is_some_and(|v| v.as_ref() == "page");
1501 }
1502 }
1503 false
1504}
1505
1506fn find_word_document_xml(archive: &mut zip::ZipArchive<File>) -> Result<String> {
1508 for i in 0..archive.len() {
1509 let entry = archive
1510 .by_index(i)
1511 .map_err(|e| DocError::Zip(e.to_string()))?;
1512 if entry.name().to_lowercase() == "word/document.xml" {
1513 return Ok(entry.name().to_owned());
1514 }
1515 }
1516 Err(DocError::Format(
1517 "word/document.xml not found in DOCX archive".to_owned(),
1518 ))
1519}
1520
1521#[cfg(test)]
1526mod tests {
1527 use super::*;
1528 use easydoc_core::ContentCollector;
1529
1530 fn make_docx_xml(xml: &[u8]) -> Vec<u8> {
1533 use std::io::Write;
1534 let mut buf = Vec::new();
1535 {
1536 let w = std::io::Cursor::new(&mut buf);
1537 let mut zip = zip::ZipWriter::new(w);
1538 let options = zip::write::SimpleFileOptions::default()
1539 .compression_method(zip::CompressionMethod::Stored);
1540 zip.start_file("word/document.xml", options).unwrap();
1541 zip.write_all(xml).unwrap();
1542 zip.finish().unwrap();
1543 }
1544 buf
1545 }
1546
1547 fn write_temp_docx(xml: &[u8]) -> tempfile::NamedTempFile {
1549 let data = make_docx_xml(xml);
1550 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1551 std::io::Write::write_all(&mut tmp, &data).unwrap();
1552 tmp
1553 }
1554
1555 #[test]
1556 fn empty_document_emits_start_end() {
1557 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1558<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1559 <w:body/>
1560</w:document>"#;
1561 let tmp = write_temp_docx(xml);
1562 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1563 let mut collector = ContentCollector::new();
1564 reader.read_events(&mut collector).unwrap();
1565 let content = collector.into_content();
1566 assert!(content.blocks.is_empty());
1567 }
1568
1569 #[test]
1570 fn simple_paragraph() {
1571 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1572<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1573 <w:body>
1574 <w:p>
1575 <w:r><w:t>Hello World</w:t></w:r>
1576 </w:p>
1577 </w:body>
1578</w:document>"#;
1579 let tmp = write_temp_docx(xml);
1580 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1581 let mut collector = ContentCollector::new();
1582 reader.read_events(&mut collector).unwrap();
1583 let content = collector.into_content();
1584 assert_eq!(content.blocks.len(), 1);
1585 match &content.blocks[0] {
1586 easydoc_core::DocumentBlock::Paragraph(runs) => {
1587 assert_eq!(runs.len(), 1);
1588 assert_eq!(runs[0].text, "Hello World");
1589 }
1590 _ => panic!("expected Paragraph"),
1591 }
1592 }
1593
1594 #[test]
1595 fn heading_detection() {
1596 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1597<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1598 <w:body>
1599 <w:p>
1600 <w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
1601 <w:r><w:t>Title</w:t></w:r>
1602 </w:p>
1603 </w:body>
1604</w:document>"#;
1605 let tmp = write_temp_docx(xml);
1606 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1607 let mut collector = ContentCollector::new();
1608 reader.read_events(&mut collector).unwrap();
1609 let content = collector.into_content();
1610 assert_eq!(content.blocks.len(), 1);
1611 match &content.blocks[0] {
1612 easydoc_core::DocumentBlock::Heading { level, runs } => {
1613 assert_eq!(*level, 1);
1614 assert_eq!(runs[0].text, "Title");
1615 }
1616 _ => panic!("expected Heading"),
1617 }
1618 }
1619
1620 #[test]
1621 fn bold_run() {
1622 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1623<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1624 <w:body>
1625 <w:p>
1626 <w:r><w:rPr><w:b/></w:rPr><w:t>Bold</w:t></w:r>
1627 </w:p>
1628 </w:body>
1629</w:document>"#;
1630 let tmp = write_temp_docx(xml);
1631 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1632 let mut collector = ContentCollector::new();
1633 reader.read_events(&mut collector).unwrap();
1634 let content = collector.into_content();
1635 match &content.blocks[0] {
1636 easydoc_core::DocumentBlock::Paragraph(runs) => {
1637 assert!(runs[0].bold);
1638 assert!(!runs[0].italic);
1639 }
1640 _ => panic!("expected Paragraph"),
1641 }
1642 }
1643
1644 #[test]
1645 fn page_break() {
1646 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1647<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1648 <w:body>
1649 <w:p>
1650 <w:r><w:br w:type="page"/></w:r>
1651 <w:r><w:t>After break</w:t></w:r>
1652 </w:p>
1653 </w:body>
1654</w:document>"#;
1655 let tmp = write_temp_docx(xml);
1656 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1657 let mut collector = ContentCollector::new();
1658 reader.read_events(&mut collector).unwrap();
1659 let content = collector.into_content();
1660 assert_eq!(content.blocks.len(), 2);
1662 assert!(matches!(
1663 content.blocks[0],
1664 easydoc_core::DocumentBlock::PageBreak
1665 ));
1666 }
1667
1668 #[test]
1669 fn simple_table() {
1670 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1671<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1672 <w:body>
1673 <w:tbl>
1674 <w:tr>
1675 <w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>
1676 <w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>
1677 </w:tr>
1678 <w:tr>
1679 <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
1680 <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
1681 </w:tr>
1682 </w:tbl>
1683 </w:body>
1684</w:document>"#;
1685 let tmp = write_temp_docx(xml);
1686 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1687 let mut collector = ContentCollector::new();
1688 reader.read_events(&mut collector).unwrap();
1689 let content = collector.into_content();
1690 assert_eq!(content.blocks.len(), 1);
1691 match &content.blocks[0] {
1692 easydoc_core::DocumentBlock::Table(table) => {
1693 assert_eq!(table.rows.len(), 2);
1694 assert_eq!(table.rows[0].cells.len(), 2);
1695 let cell_text: String = table.rows[0].cells[0]
1697 .blocks
1698 .iter()
1699 .filter_map(|b| match b {
1700 easydoc_core::DocumentBlock::Paragraph(runs) => {
1701 Some(runs.iter().map(|r| r.text.as_str()).collect::<String>())
1702 }
1703 _ => None,
1704 })
1705 .collect();
1706 assert_eq!(cell_text, "A1");
1707 }
1708 _ => panic!("expected Table"),
1709 }
1710 }
1711
1712 #[test]
1713 fn mixed_content_paragraph_and_table() {
1714 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1715<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1716 <w:body>
1717 <w:p><w:r><w:t>Before</w:t></w:r></w:p>
1718 <w:tbl>
1719 <w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr>
1720 </w:tbl>
1721 <w:p><w:r><w:t>After</w:t></w:r></w:p>
1722 </w:body>
1723</w:document>"#;
1724 let tmp = write_temp_docx(xml);
1725 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1726 let mut collector = ContentCollector::new();
1727 reader.read_events(&mut collector).unwrap();
1728 let content = collector.into_content();
1729 assert_eq!(content.blocks.len(), 3);
1730 assert!(matches!(
1731 content.blocks[0],
1732 easydoc_core::DocumentBlock::Paragraph(_)
1733 ));
1734 assert!(matches!(
1735 content.blocks[1],
1736 easydoc_core::DocumentBlock::Table(_)
1737 ));
1738 assert!(matches!(
1739 content.blocks[2],
1740 easydoc_core::DocumentBlock::Paragraph(_)
1741 ));
1742 }
1743
1744 #[test]
1745 fn from_reader_basic() {
1746 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1747<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1748 <w:body>
1749 <w:p><w:r><w:t>Direct</w:t></w:r></w:p>
1750 </w:body>
1751</w:document>"#;
1752 let mut reader = DocxSaxReader::from_reader(&xml[..]);
1753 let mut collector = ContentCollector::new();
1754 reader.read_events(&mut collector).unwrap();
1755 let content = collector.into_content();
1756 assert_eq!(content.blocks.len(), 1);
1757 }
1758
1759 #[test]
1760 fn parse_heading_level_variants() {
1761 assert_eq!(parse_heading_level("Heading1"), Some(1));
1762 assert_eq!(parse_heading_level("Heading2"), Some(2));
1763 assert_eq!(parse_heading_level("heading3"), Some(3));
1764 assert_eq!(parse_heading_level("heading 4"), Some(4));
1765 assert_eq!(parse_heading_level("Heading7"), None);
1766 assert_eq!(parse_heading_level("Normal"), None);
1767 assert_eq!(parse_heading_level("Title"), None);
1768 }
1769
1770 #[test]
1771 fn drawing_emits_placeholder_image() {
1772 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1773<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1774 <w:body>
1775 <w:p>
1776 <w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"/></w:drawing></w:r>
1777 </w:p>
1778 </w:body>
1779</w:document>"#;
1780 let tmp = write_temp_docx(xml);
1781 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1782 let mut collector = ContentCollector::new();
1783 reader.read_events(&mut collector).unwrap();
1784 let content = collector.into_content();
1785 let has_image = content
1786 .blocks
1787 .iter()
1788 .any(|b| matches!(b, easydoc_core::DocumentBlock::Image(_)));
1789 assert!(has_image, "expected an Image block from drawing");
1790 }
1791
1792 #[test]
1793 fn italic_and_strikethrough() {
1794 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1795<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1796 <w:body>
1797 <w:p>
1798 <w:r><w:rPr><w:i/><w:strike/></w:rPr><w:t>Fancy</w:t></w:r>
1799 </w:p>
1800 </w:body>
1801</w:document>"#;
1802 let tmp = write_temp_docx(xml);
1803 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1804 let mut collector = ContentCollector::new();
1805 reader.read_events(&mut collector).unwrap();
1806 let content = collector.into_content();
1807 match &content.blocks[0] {
1808 easydoc_core::DocumentBlock::Paragraph(runs) => {
1809 assert!(runs[0].italic);
1810 assert!(runs[0].strikethrough);
1811 assert!(!runs[0].bold);
1812 }
1813 _ => panic!("expected Paragraph"),
1814 }
1815 }
1816
1817 #[test]
1818 fn multiple_runs_in_paragraph() {
1819 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1820<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1821 <w:body>
1822 <w:p>
1823 <w:r><w:t>Hello </w:t></w:r>
1824 <w:r><w:rPr><w:b/></w:rPr><w:t>World</w:t></w:r>
1825 </w:p>
1826 </w:body>
1827</w:document>"#;
1828 let tmp = write_temp_docx(xml);
1829 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1830 let mut collector = ContentCollector::new();
1831 reader.read_events(&mut collector).unwrap();
1832 let content = collector.into_content();
1833 match &content.blocks[0] {
1834 easydoc_core::DocumentBlock::Paragraph(runs) => {
1835 assert_eq!(runs.len(), 2);
1836 assert_eq!(runs[0].text, "Hello ");
1837 assert!(!runs[0].bold);
1838 assert_eq!(runs[1].text, "World");
1839 assert!(runs[1].bold);
1840 }
1841 _ => panic!("expected Paragraph"),
1842 }
1843 }
1844
1845 const MINIMAL_PNG: &[u8] = &[
1847 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
1848 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
1849 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
1850 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
1851 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
1852 ];
1853
1854 fn make_docx_with_image(xml: &[u8], rels_xml: &[u8], image_bytes: &[u8]) -> Vec<u8> {
1857 use std::io::Write;
1858 let mut buf = Vec::new();
1859 {
1860 let w = std::io::Cursor::new(&mut buf);
1861 let mut zip = zip::ZipWriter::new(w);
1862 let options = zip::write::SimpleFileOptions::default()
1863 .compression_method(zip::CompressionMethod::Stored);
1864
1865 zip.start_file("word/document.xml", options).unwrap();
1866 zip.write_all(xml).unwrap();
1867
1868 zip.start_file("word/_rels/document.xml.rels", options)
1869 .unwrap();
1870 zip.write_all(rels_xml).unwrap();
1871
1872 zip.start_file("word/media/image1.png", options).unwrap();
1873 zip.write_all(image_bytes).unwrap();
1874
1875 zip.finish().unwrap();
1876 }
1877 buf
1878 }
1879
1880 fn write_temp_docx_with_image(
1882 xml: &[u8],
1883 rels_xml: &[u8],
1884 image_bytes: &[u8],
1885 ) -> tempfile::NamedTempFile {
1886 let data = make_docx_with_image(xml, rels_xml, image_bytes);
1887 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1888 std::io::Write::write_all(&mut tmp, &data).unwrap();
1889 tmp
1890 }
1891
1892 #[test]
1893 fn from_reader_drawing_emits_placeholder_no_data() {
1894 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1896<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1897 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
1898 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
1899 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
1900 <w:body>
1901 <w:p>
1902 <w:r>
1903 <w:drawing>
1904 <wp:inline>
1905 <wp:docPr id="1" name="Picture 1" descr="My photo"/>
1906 <a:graphic>
1907 <a:graphicData>
1908 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
1909 <pic:blipFill>
1910 <a:blip r:embed="rId5"/>
1911 </pic:blipFill>
1912 </pic:pic>
1913 </a:graphicData>
1914 </a:graphic>
1915 </wp:inline>
1916 </w:drawing>
1917 </w:r>
1918 </w:p>
1919 </w:body>
1920</w:document>"#;
1921 let mut reader = DocxSaxReader::from_reader(&xml[..]);
1922 let mut collector = ContentCollector::new();
1923 reader.read_events(&mut collector).unwrap();
1924 let content = collector.into_content();
1925
1926 let img = content
1927 .blocks
1928 .iter()
1929 .find_map(|b| match b {
1930 easydoc_core::DocumentBlock::Image(img) => Some(img),
1931 _ => None,
1932 })
1933 .expect("expected an Image block");
1934
1935 assert!(img.data.is_none());
1937 assert_eq!(img.alt_text.as_deref(), Some("My photo"));
1939 }
1940
1941 #[test]
1942 fn from_path_drawing_extracts_real_image_data() {
1943 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1944<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1945 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
1946 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
1947 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
1948 <w:body>
1949 <w:p>
1950 <w:r>
1951 <w:drawing>
1952 <wp:inline>
1953 <wp:docPr id="1" name="Picture 1" descr="A tiny image"/>
1954 <a:graphic>
1955 <a:graphicData>
1956 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
1957 <pic:blipFill>
1958 <a:blip r:embed="rId5"/>
1959 </pic:blipFill>
1960 </pic:pic>
1961 </a:graphicData>
1962 </a:graphic>
1963 </wp:inline>
1964 </w:drawing>
1965 </w:r>
1966 </w:p>
1967 </w:body>
1968</w:document>"#;
1969
1970 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1971<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1972 <Relationship Id="rId1" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
1973 <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
1974</Relationships>"#;
1975
1976 let tmp = write_temp_docx_with_image(xml, rels_xml, MINIMAL_PNG);
1977 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1978 let mut collector = ContentCollector::new();
1979 reader.read_events(&mut collector).unwrap();
1980 let content = collector.into_content();
1981
1982 let img = content
1983 .blocks
1984 .iter()
1985 .find_map(|b| match b {
1986 easydoc_core::DocumentBlock::Image(img) => Some(img),
1987 _ => None,
1988 })
1989 .expect("expected an Image block");
1990
1991 assert_eq!(img.data.as_deref(), Some(MINIMAL_PNG));
1993 assert_eq!(img.extension.as_deref(), Some("png"));
1995 assert_eq!(img.alt_text.as_deref(), Some("A tiny image"));
1997 }
1998
1999 #[test]
2000 fn from_path_drawing_without_rels_emits_placeholder() {
2001 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2003<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2004 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2005 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2006 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2007 <w:body>
2008 <w:p>
2009 <w:r>
2010 <w:drawing>
2011 <wp:inline>
2012 <wp:docPr id="1" name="Pic"/>
2013 <a:graphic>
2014 <a:graphicData>
2015 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2016 <pic:blipFill>
2017 <a:blip r:embed="rId5"/>
2018 </pic:blipFill>
2019 </pic:pic>
2020 </a:graphicData>
2021 </a:graphic>
2022 </wp:inline>
2023 </w:drawing>
2024 </w:r>
2025 </w:p>
2026 </w:body>
2027</w:document>"#;
2028 let tmp = write_temp_docx(xml);
2029 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2030 let mut collector = ContentCollector::new();
2031 reader.read_events(&mut collector).unwrap();
2032 let content = collector.into_content();
2033
2034 let img = content
2035 .blocks
2036 .iter()
2037 .find_map(|b| match b {
2038 easydoc_core::DocumentBlock::Image(img) => Some(img),
2039 _ => None,
2040 })
2041 .expect("expected an Image block");
2042
2043 assert!(img.data.is_none());
2045 assert_eq!(img.alt_text.as_deref(), Some("[image]"));
2048 }
2049
2050 #[test]
2051 fn from_path_drawing_alt_from_name_when_no_descr() {
2052 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2053<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2054 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2055 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2056 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2057 <w:body>
2058 <w:p>
2059 <w:r>
2060 <w:drawing>
2061 <wp:inline>
2062 <wp:docPr id="1" name="Diagram"/>
2063 <a:graphic>
2064 <a:graphicData>
2065 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2066 <pic:blipFill>
2067 <a:blip r:embed="rId5"/>
2068 </pic:blipFill>
2069 </pic:pic>
2070 </a:graphicData>
2071 </a:graphic>
2072 </wp:inline>
2073 </w:drawing>
2074 </w:r>
2075 </w:p>
2076 </w:body>
2077</w:document>"#;
2078 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2079<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2080 <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
2081</Relationships>"#;
2082
2083 let tmp = write_temp_docx_with_image(xml, rels_xml, MINIMAL_PNG);
2084 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2085 let mut collector = ContentCollector::new();
2086 reader.read_events(&mut collector).unwrap();
2087 let content = collector.into_content();
2088
2089 let img = content
2090 .blocks
2091 .iter()
2092 .find_map(|b| match b {
2093 easydoc_core::DocumentBlock::Image(img) => Some(img),
2094 _ => None,
2095 })
2096 .expect("expected an Image block");
2097
2098 assert_eq!(img.alt_text.as_deref(), Some("[image]"));
2102 }
2103
2104 #[test]
2105 fn from_path_drawing_jpeg_extension() {
2106 use std::io::Write;
2107 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2108<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2109 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2110 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2111 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2112 <w:body>
2113 <w:p>
2114 <w:r>
2115 <w:drawing>
2116 <wp:inline>
2117 <wp:docPr id="1" name="Pic"/>
2118 <a:graphic>
2119 <a:graphicData>
2120 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2121 <pic:blipFill>
2122 <a:blip r:embed="rId5"/>
2123 </pic:blipFill>
2124 </pic:pic>
2125 </a:graphicData>
2126 </a:graphic>
2127 </wp:inline>
2128 </w:drawing>
2129 </w:r>
2130 </w:p>
2131 </w:body>
2132</w:document>"#;
2133
2134 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2135<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2136 <Relationship Id="rId5" Target="media/photo.jpeg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
2137</Relationships>"#;
2138
2139 let zip_bytes = {
2141 let mut buf = Vec::new();
2142 {
2143 let w = std::io::Cursor::new(&mut buf);
2144 let mut zip = zip::ZipWriter::new(w);
2145 let options = zip::write::SimpleFileOptions::default()
2146 .compression_method(zip::CompressionMethod::Stored);
2147
2148 zip.start_file("word/document.xml", options).unwrap();
2149 zip.write_all(xml).unwrap();
2150
2151 zip.start_file("word/_rels/document.xml.rels", options)
2152 .unwrap();
2153 zip.write_all(rels_xml).unwrap();
2154
2155 zip.start_file("word/media/photo.jpeg", options).unwrap();
2157 zip.write_all(MINIMAL_PNG).unwrap();
2158
2159 zip.finish().unwrap();
2160 }
2161 buf
2162 };
2163
2164 let mut tmp = tempfile::NamedTempFile::new().unwrap();
2165 std::io::Write::write_all(&mut tmp, &zip_bytes).unwrap();
2166 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2167 let mut collector = ContentCollector::new();
2168 reader.read_events(&mut collector).unwrap();
2169 let content = collector.into_content();
2170
2171 let img = content
2172 .blocks
2173 .iter()
2174 .find_map(|b| match b {
2175 easydoc_core::DocumentBlock::Image(img) => Some(img),
2176 _ => None,
2177 })
2178 .expect("expected an Image block");
2179
2180 assert_eq!(img.data.as_deref(), Some(MINIMAL_PNG));
2181 assert_eq!(img.extension.as_deref(), Some("jpeg"));
2182 }
2183
2184 #[test]
2189 fn cell_without_merge_has_default_span() {
2190 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2191<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2192 <w:body>
2193 <w:tbl>
2194 <w:tr>
2195 <w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc>
2196 <w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc>
2197 </w:tr>
2198 </w:tbl>
2199 </w:body>
2200</w:document>"#;
2201 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2202 let mut collector = ContentCollector::new();
2203 reader.read_events(&mut collector).unwrap();
2204 let content = collector.into_content();
2205
2206 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2207 panic!("expected Table")
2208 };
2209 assert_eq!(table.rows[0].cells.len(), 2);
2210 assert_eq!(table.rows[0].cells[0].column_span, 1);
2211 assert_eq!(table.rows[0].cells[0].row_span, 1);
2212 assert_eq!(table.rows[0].cells[1].column_span, 1);
2213 assert_eq!(table.rows[0].cells[1].row_span, 1);
2214 }
2215
2216 #[test]
2217 fn gridspan_horizontal_merge_two_columns() {
2218 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2219<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2220 <w:body>
2221 <w:tbl>
2222 <w:tr>
2223 <w:tc>
2224 <w:tcPr><w:gridSpan w:val="2"/></w:tcPr>
2225 <w:p><w:r><w:t>Merged</w:t></w:r></w:p>
2226 </w:tc>
2227 </w:tr>
2228 <w:tr>
2229 <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
2230 <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
2231 </w:tr>
2232 </w:tbl>
2233 </w:body>
2234</w:document>"#;
2235 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2236 let mut collector = ContentCollector::new();
2237 reader.read_events(&mut collector).unwrap();
2238 let content = collector.into_content();
2239
2240 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2241 panic!("expected Table")
2242 };
2243 assert_eq!(table.rows[0].cells.len(), 1);
2245 assert_eq!(table.rows[0].cells[0].column_span, 2);
2246 assert_eq!(table.rows[0].cells[0].row_span, 1);
2247 assert_eq!(table.rows[1].cells.len(), 2);
2249 assert_eq!(table.rows[1].cells[0].column_span, 1);
2250 assert_eq!(table.rows[1].cells[1].column_span, 1);
2251 }
2252
2253 #[test]
2254 fn gridspan_horizontal_merge_three_columns() {
2255 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2256<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2257 <w:body>
2258 <w:tbl>
2259 <w:tr>
2260 <w:tc>
2261 <w:tcPr><w:gridSpan w:val="3"/></w:tcPr>
2262 <w:p><w:r><w:t>Wide</w:t></w:r></w:p>
2263 </w:tc>
2264 </w:tr>
2265 </w:tbl>
2266 </w:body>
2267</w:document>"#;
2268 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2269 let mut collector = ContentCollector::new();
2270 reader.read_events(&mut collector).unwrap();
2271 let content = collector.into_content();
2272
2273 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2274 panic!("expected Table")
2275 };
2276 assert_eq!(table.rows[0].cells.len(), 1);
2277 assert_eq!(table.rows[0].cells[0].column_span, 3);
2278 }
2279
2280 #[test]
2281 fn vmerge_restart_sets_row_span_one() {
2282 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2283<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2284 <w:body>
2285 <w:tbl>
2286 <w:tr>
2287 <w:tc>
2288 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2289 <w:p><w:r><w:t>Start</w:t></w:r></w:p>
2290 </w:tc>
2291 <w:tc><w:p><w:r><w:t>Right</w:t></w:r></w:p></w:tc>
2292 </w:tr>
2293 </w:tbl>
2294 </w:body>
2295</w:document>"#;
2296 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2297 let mut collector = ContentCollector::new();
2298 reader.read_events(&mut collector).unwrap();
2299 let content = collector.into_content();
2300
2301 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2302 panic!("expected Table")
2303 };
2304 assert_eq!(table.rows[0].cells[0].row_span, 1);
2306 assert_eq!(table.rows[0].cells[0].column_span, 1);
2307 }
2308
2309 #[test]
2310 fn vmerge_continue_sets_row_span_zero() {
2311 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2312<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2313 <w:body>
2314 <w:tbl>
2315 <w:tr>
2316 <w:tc>
2317 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2318 <w:p><w:r><w:t>Start</w:t></w:r></w:p>
2319 </w:tc>
2320 <w:tc><w:p><w:r><w:t>R1</w:t></w:r></w:p></w:tc>
2321 </w:tr>
2322 <w:tr>
2323 <w:tc>
2324 <w:tcPr><w:vMerge w:val="continue"/></w:tcPr>
2325 <w:p/>
2326 </w:tc>
2327 <w:tc><w:p><w:r><w:t>R2</w:t></w:r></w:p></w:tc>
2328 </w:tr>
2329 </w:tbl>
2330 </w:body>
2331</w:document>"#;
2332 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2333 let mut collector = ContentCollector::new();
2334 reader.read_events(&mut collector).unwrap();
2335 let content = collector.into_content();
2336
2337 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2338 panic!("expected Table")
2339 };
2340 assert_eq!(table.rows[0].cells[0].row_span, 1);
2342 assert_eq!(table.rows[1].cells[0].row_span, 0);
2344 }
2345
2346 #[test]
2347 fn vmerge_no_val_treated_as_continue() {
2348 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2350<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2351 <w:body>
2352 <w:tbl>
2353 <w:tr>
2354 <w:tc>
2355 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2356 <w:p><w:r><w:t>Top</w:t></w:r></w:p>
2357 </w:tc>
2358 </w:tr>
2359 <w:tr>
2360 <w:tc>
2361 <w:tcPr><w:vMerge/></w:tcPr>
2362 <w:p/>
2363 </w:tc>
2364 </w:tr>
2365 <w:tr>
2366 <w:tc>
2367 <w:tcPr><w:vMerge/></w:tcPr>
2368 <w:p/>
2369 </w:tc>
2370 </w:tr>
2371 </w:tbl>
2372 </w:body>
2373</w:document>"#;
2374 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2375 let mut collector = ContentCollector::new();
2376 reader.read_events(&mut collector).unwrap();
2377 let content = collector.into_content();
2378
2379 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2380 panic!("expected Table")
2381 };
2382 assert_eq!(table.rows[0].cells[0].row_span, 1);
2384 assert_eq!(table.rows[1].cells[0].row_span, 0);
2386 assert_eq!(table.rows[2].cells[0].row_span, 0);
2388 }
2389
2390 #[test]
2391 fn mixed_gridspan_and_vmerge() {
2392 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2393<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2394 <w:body>
2395 <w:tbl>
2396 <w:tr>
2397 <w:tc>
2398 <w:tcPr>
2399 <w:gridSpan w:val="2"/>
2400 <w:vMerge w:val="restart"/>
2401 </w:tcPr>
2402 <w:p><w:r><w:t>Big</w:t></w:r></w:p>
2403 </w:tc>
2404 <w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc>
2405 </w:tr>
2406 <w:tr>
2407 <w:tc>
2408 <w:tcPr>
2409 <w:gridSpan w:val="2"/>
2410 <w:vMerge w:val="continue"/>
2411 </w:tcPr>
2412 <w:p/>
2413 </w:tc>
2414 <w:tc><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc>
2415 </w:tr>
2416 </w:tbl>
2417 </w:body>
2418</w:document>"#;
2419 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2420 let mut collector = ContentCollector::new();
2421 reader.read_events(&mut collector).unwrap();
2422 let content = collector.into_content();
2423
2424 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2425 panic!("expected Table")
2426 };
2427 assert_eq!(table.rows[0].cells[0].column_span, 2);
2429 assert_eq!(table.rows[0].cells[0].row_span, 1);
2430 assert_eq!(table.rows[1].cells[0].column_span, 2);
2432 assert_eq!(table.rows[1].cells[0].row_span, 0);
2433 }
2434
2435 #[test]
2436 fn gridspan_val_one_is_noop() {
2437 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2439<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2440 <w:body>
2441 <w:tbl>
2442 <w:tr>
2443 <w:tc>
2444 <w:tcPr><w:gridSpan w:val="1"/></w:tcPr>
2445 <w:p><w:r><w:t>X</w:t></w:r></w:p>
2446 </w:tc>
2447 <w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
2448 </w:tr>
2449 </w:tbl>
2450 </w:body>
2451</w:document>"#;
2452 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2453 let mut collector = ContentCollector::new();
2454 reader.read_events(&mut collector).unwrap();
2455 let content = collector.into_content();
2456
2457 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2458 panic!("expected Table")
2459 };
2460 assert_eq!(table.rows[0].cells[0].column_span, 1);
2461 assert_eq!(table.rows[0].cells[1].column_span, 1);
2462 }
2463
2464 #[test]
2469 fn inline_math_in_paragraph() {
2470 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2472<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2473 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2474 <w:body>
2475 <w:p>
2476 <w:r><w:t>Formula: </w:t></w:r>
2477 <m:oMath><m:r><m:t>x</m:t></m:r></m:oMath>
2478 </w:p>
2479 </w:body>
2480</w:document>"#;
2481 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2482 let blocks = reader.read_blocks().unwrap();
2483 assert_eq!(blocks.len(), 2);
2485 match &blocks[0] {
2486 DocumentBlock::Paragraph(runs) => {
2487 assert_eq!(runs.len(), 1);
2488 assert_eq!(runs[0].text, "Formula: ");
2489 }
2490 other => panic!("expected Paragraph, got {other:?}"),
2491 }
2492 match &blocks[1] {
2493 DocumentBlock::Math {
2494 omml,
2495 latex,
2496 display,
2497 } => {
2498 let xml_str = omml.as_ref().expect("omml should be Some");
2499 assert!(xml_str.contains("<m:oMath>"), "omml = {xml_str}");
2500 assert!(xml_str.contains("</m:oMath>"), "omml = {xml_str}");
2501 assert!(
2502 xml_str.contains("<m:r><m:t>x</m:t></m:r>"),
2503 "omml = {xml_str}"
2504 );
2505 assert!(latex.is_none());
2506 assert!(!display, "inline math should have display=false");
2507 }
2508 other => panic!("expected Math, got {other:?}"),
2509 }
2510 }
2511
2512 #[test]
2513 fn display_math_with_omathpara() {
2514 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2516<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2517 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2518 <w:body>
2519 <w:p><w:r><w:t>Before</w:t></w:r></w:p>
2520 <m:oMathPara><m:oMath><m:r><m:t>E=mc^2</m:t></m:r></m:oMath></m:oMathPara>
2521 <w:p><w:r><w:t>After</w:t></w:r></w:p>
2522 </w:body>
2523</w:document>"#;
2524 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2525 let blocks = reader.read_blocks().unwrap();
2526 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2528 match &blocks[0] {
2529 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Before"),
2530 other => panic!("expected Paragraph, got {other:?}"),
2531 }
2532 match &blocks[1] {
2533 DocumentBlock::Math { omml, display, .. } => {
2534 let xml_str = omml.as_ref().expect("omml should be Some");
2535 assert!(xml_str.contains("<m:oMathPara>"), "omml = {xml_str}");
2536 assert!(xml_str.contains("</m:oMathPara>"), "omml = {xml_str}");
2537 assert!(*display, "oMathPara should have display=true");
2538 }
2539 other => panic!("expected Math, got {other:?}"),
2540 }
2541 match &blocks[2] {
2542 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "After"),
2543 other => panic!("expected Paragraph, got {other:?}"),
2544 }
2545 }
2546
2547 #[test]
2548 fn mixed_text_math_text_in_paragraph() {
2549 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2551<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2552 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2553 <w:body>
2554 <w:p>
2555 <w:r><w:t>Let </w:t></w:r>
2556 <m:oMath><m:r><m:t>y</m:t></m:r></m:oMath>
2557 <w:r><w:t> be the result.</w:t></w:r>
2558 </w:p>
2559 </w:body>
2560</w:document>"#;
2561 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2562 let blocks = reader.read_blocks().unwrap();
2563 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2565 match &blocks[0] {
2566 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Let "),
2567 other => panic!("expected Paragraph, got {other:?}"),
2568 }
2569 assert!(matches!(
2570 &blocks[1],
2571 DocumentBlock::Math { display: false, .. }
2572 ));
2573 match &blocks[2] {
2574 DocumentBlock::Paragraph(runs) => {
2575 assert_eq!(runs[0].text, " be the result.");
2576 }
2577 other => panic!("expected Paragraph, got {other:?}"),
2578 }
2579 }
2580
2581 #[test]
2582 fn nested_math_structure() {
2583 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2585<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2586 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2587 <w:body>
2588 <m:oMath>
2589 <m:f>
2590 <m:num><m:r><m:t>a</m:t></m:r></m:num>
2591 <m:den><m:r><m:t>b</m:t></m:r></m:den>
2592 </m:f>
2593 </m:oMath>
2594 </w:body>
2595</w:document>"#;
2596 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2597 let blocks = reader.read_blocks().unwrap();
2598 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2599 match &blocks[0] {
2600 DocumentBlock::Math { omml, display, .. } => {
2601 let xml_str = omml.as_ref().expect("omml should be Some");
2602 assert!(xml_str.contains("<m:f>"), "omml = {xml_str}");
2604 assert!(xml_str.contains("</m:f>"), "omml = {xml_str}");
2605 assert!(xml_str.contains("<m:num>"), "omml = {xml_str}");
2606 assert!(xml_str.contains("<m:den>"), "omml = {xml_str}");
2607 assert!(xml_str.contains("<m:t>a</m:t>"), "omml = {xml_str}");
2608 assert!(xml_str.contains("<m:t>b</m:t>"), "omml = {xml_str}");
2609 assert!(!display, "standalone oMath should have display=false");
2610 }
2611 other => panic!("expected Math, got {other:?}"),
2612 }
2613 }
2614
2615 #[test]
2616 fn block_level_math_without_omathpara() {
2617 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2619<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2620 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2621 <w:body>
2622 <w:p><w:r><w:t>See equation:</w:t></w:r></w:p>
2623 <m:oMath><m:r><m:t>x+1=0</m:t></m:r></m:oMath>
2624 </w:body>
2625</w:document>"#;
2626 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2627 let blocks = reader.read_blocks().unwrap();
2628 assert_eq!(blocks.len(), 2, "blocks = {blocks:?}");
2629 assert!(matches!(&blocks[0], DocumentBlock::Paragraph(_)));
2630 match &blocks[1] {
2631 DocumentBlock::Math { omml, display, .. } => {
2632 let xml_str = omml.as_ref().expect("omml should be Some");
2633 assert!(xml_str.contains("<m:oMath>"));
2634 assert!(
2635 !display,
2636 "bare oMathPara-less math should have display=false"
2637 );
2638 }
2639 other => panic!("expected Math, got {other:?}"),
2640 }
2641 }
2642
2643 #[test]
2644 fn multiple_math_in_one_paragraph() {
2645 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2647<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2648 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2649 <w:body>
2650 <w:p>
2651 <m:oMath><m:r><m:t>a</m:t></m:r></m:oMath>
2652 <w:r><w:t> + </w:t></w:r>
2653 <m:oMath><m:r><m:t>b</m:t></m:r></m:oMath>
2654 </w:p>
2655 </w:body>
2656</w:document>"#;
2657 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2658 let blocks = reader.read_blocks().unwrap();
2659 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2661 assert!(matches!(
2662 &blocks[0],
2663 DocumentBlock::Math { display: false, .. }
2664 ));
2665 match &blocks[1] {
2666 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, " + "),
2667 other => panic!("expected Paragraph, got {other:?}"),
2668 }
2669 assert!(matches!(
2670 &blocks[2],
2671 DocumentBlock::Math { display: false, .. }
2672 ));
2673 }
2674
2675 #[test]
2680 fn single_list_item() {
2681 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2682<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2683 <w:body>
2684 <w:p>
2685 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2686 <w:r><w:t>Item one</w:t></w:r>
2687 </w:p>
2688 </w:body>
2689</w:document>"#;
2690 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2691 let blocks = reader.read_blocks().unwrap();
2692 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2693 match &blocks[0] {
2694 DocumentBlock::List(list) => {
2695 assert_eq!(list.items.len(), 1);
2696 match &list.items[0].blocks[0] {
2697 DocumentBlock::Paragraph(runs) => {
2698 assert_eq!(runs[0].text, "Item one");
2699 }
2700 other => panic!("expected Paragraph inside list item, got {other:?}"),
2701 }
2702 }
2703 other => panic!("expected List, got {other:?}"),
2704 }
2705 }
2706
2707 #[test]
2708 fn consecutive_list_items_merged() {
2709 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2710<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2711 <w:body>
2712 <w:p>
2713 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2714 <w:r><w:t>First</w:t></w:r>
2715 </w:p>
2716 <w:p>
2717 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2718 <w:r><w:t>Second</w:t></w:r>
2719 </w:p>
2720 <w:p>
2721 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2722 <w:r><w:t>Third</w:t></w:r>
2723 </w:p>
2724 </w:body>
2725</w:document>"#;
2726 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2727 let blocks = reader.read_blocks().unwrap();
2728 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2730 match &blocks[0] {
2731 DocumentBlock::List(list) => {
2732 assert_eq!(list.items.len(), 3);
2733 }
2734 other => panic!("expected List, got {other:?}"),
2735 }
2736 }
2737
2738 #[test]
2739 fn list_followed_by_paragraph_flushes() {
2740 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2741<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2742 <w:body>
2743 <w:p>
2744 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2745 <w:r><w:t>List item</w:t></w:r>
2746 </w:p>
2747 <w:p>
2748 <w:r><w:t>Normal paragraph</w:t></w:r>
2749 </w:p>
2750 </w:body>
2751</w:document>"#;
2752 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2753 let blocks = reader.read_blocks().unwrap();
2754 assert_eq!(blocks.len(), 2, "blocks = {blocks:?}");
2755 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2756 match &blocks[1] {
2757 DocumentBlock::Paragraph(runs) => {
2758 assert_eq!(runs[0].text, "Normal paragraph");
2759 }
2760 other => panic!("expected Paragraph, got {other:?}"),
2761 }
2762 }
2763
2764 #[test]
2765 fn two_separate_lists() {
2766 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2767<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2768 <w:body>
2769 <w:p>
2770 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2771 <w:r><w:t>A</w:t></w:r>
2772 </w:p>
2773 <w:p>
2774 <w:r><w:t>Separator</w:t></w:r>
2775 </w:p>
2776 <w:p>
2777 <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2778 <w:r><w:t>B</w:t></w:r>
2779 </w:p>
2780 </w:body>
2781</w:document>"#;
2782 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2783 let blocks = reader.read_blocks().unwrap();
2784 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2785 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2786 assert!(matches!(&blocks[1], DocumentBlock::Paragraph(_)));
2787 assert!(matches!(&blocks[2], DocumentBlock::List(_)));
2788 }
2789
2790 #[test]
2791 fn list_at_document_end_flushes() {
2792 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2793<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2794 <w:body>
2795 <w:p>
2796 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2797 <w:r><w:t>Last item</w:t></w:r>
2798 </w:p>
2799 </w:body>
2800</w:document>"#;
2801 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2802 let blocks = reader.read_blocks().unwrap();
2803 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2804 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2805 }
2806
2807 #[test]
2812 fn hyperlink_sets_run_field() {
2813 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2814<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2815 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2816 <w:body>
2817 <w:p>
2818 <w:hyperlink r:id="rId5">
2819 <w:r><w:t>Click here</w:t></w:r>
2820 </w:hyperlink>
2821 </w:p>
2822 </w:body>
2823</w:document>"#;
2824 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2825 let blocks = reader.read_blocks().unwrap();
2826 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2827 match &blocks[0] {
2828 DocumentBlock::Paragraph(runs) => {
2829 assert_eq!(runs.len(), 1);
2830 assert_eq!(runs[0].text, "Click here");
2831 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId5"));
2832 }
2833 other => panic!("expected Paragraph, got {other:?}"),
2834 }
2835 }
2836
2837 #[test]
2838 fn hyperlink_mixed_with_normal_runs() {
2839 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2840<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2841 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2842 <w:body>
2843 <w:p>
2844 <w:r><w:t>Normal </w:t></w:r>
2845 <w:hyperlink r:id="rId3">
2846 <w:r><w:t>link text</w:t></w:r>
2847 </w:hyperlink>
2848 <w:r><w:t> after</w:t></w:r>
2849 </w:p>
2850 </w:body>
2851</w:document>"#;
2852 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2853 let blocks = reader.read_blocks().unwrap();
2854 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2855 match &blocks[0] {
2856 DocumentBlock::Paragraph(runs) => {
2857 assert_eq!(runs.len(), 3);
2858 assert_eq!(runs[0].text, "Normal ");
2859 assert!(runs[0].hyperlink.is_none());
2860 assert_eq!(runs[1].text, "link text");
2861 assert_eq!(runs[1].hyperlink.as_deref(), Some("rId3"));
2862 assert_eq!(runs[2].text, " after");
2863 assert!(runs[2].hyperlink.is_none());
2864 }
2865 other => panic!("expected Paragraph, got {other:?}"),
2866 }
2867 }
2868
2869 #[test]
2870 fn hyperlink_with_bold_run() {
2871 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2872<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2873 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2874 <w:body>
2875 <w:p>
2876 <w:hyperlink r:id="rId10">
2877 <w:r>
2878 <w:rPr><w:b/></w:rPr>
2879 <w:t>Bold link</w:t>
2880 </w:r>
2881 </w:hyperlink>
2882 </w:p>
2883 </w:body>
2884</w:document>"#;
2885 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2886 let blocks = reader.read_blocks().unwrap();
2887 match &blocks[0] {
2888 DocumentBlock::Paragraph(runs) => {
2889 assert_eq!(runs[0].text, "Bold link");
2890 assert!(runs[0].bold);
2891 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId10"));
2892 }
2893 other => panic!("expected Paragraph, got {other:?}"),
2894 }
2895 }
2896
2897 #[test]
2898 fn no_hyperlink_field_when_not_in_hyperlink() {
2899 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2900<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2901 <w:body>
2902 <w:p>
2903 <w:r><w:t>No link</w:t></w:r>
2904 </w:p>
2905 </w:body>
2906</w:document>"#;
2907 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2908 let blocks = reader.read_blocks().unwrap();
2909 match &blocks[0] {
2910 DocumentBlock::Paragraph(runs) => {
2911 assert!(runs[0].hyperlink.is_none());
2912 }
2913 other => panic!("expected Paragraph, got {other:?}"),
2914 }
2915 }
2916
2917 #[test]
2922 fn nested_table_in_cell() {
2923 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2924<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2925 <w:body>
2926 <w:tbl>
2927 <w:tr>
2928 <w:tc>
2929 <w:p><w:r><w:t>Outer</w:t></w:r></w:p>
2930 <w:tbl>
2931 <w:tr>
2932 <w:tc><w:p><w:r><w:t>Inner A</w:t></w:r></w:p></w:tc>
2933 <w:tc><w:p><w:r><w:t>Inner B</w:t></w:r></w:p></w:tc>
2934 </w:tr>
2935 </w:tbl>
2936 </w:tc>
2937 <w:tc><w:p><w:r><w:t>Right</w:t></w:r></w:p></w:tc>
2938 </w:tr>
2939 </w:tbl>
2940 </w:body>
2941</w:document>"#;
2942 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2943 let blocks = reader.read_blocks().unwrap();
2944 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2945 match &blocks[0] {
2946 DocumentBlock::Table(outer) => {
2947 assert_eq!(outer.rows.len(), 1);
2948 assert_eq!(outer.rows[0].cells.len(), 2);
2949 let cell0 = &outer.rows[0].cells[0];
2951 assert!(cell0.blocks.len() >= 2, "cell0.blocks = {:?}", cell0.blocks);
2952 assert!(matches!(&cell0.blocks[0], DocumentBlock::Paragraph(_)));
2953 assert!(matches!(&cell0.blocks[1], DocumentBlock::Table(_)));
2954 if let DocumentBlock::Table(inner) = &cell0.blocks[1] {
2955 assert_eq!(inner.rows.len(), 1);
2956 assert_eq!(inner.rows[0].cells.len(), 2);
2957 }
2958 let cell1 = &outer.rows[0].cells[1];
2960 assert_eq!(cell1.blocks.len(), 1);
2961 }
2962 other => panic!("expected Table, got {other:?}"),
2963 }
2964 }
2965
2966 #[test]
2967 fn nested_table_only_in_cell() {
2968 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2970<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2971 <w:body>
2972 <w:tbl>
2973 <w:tr>
2974 <w:tc>
2975 <w:tbl>
2976 <w:tr>
2977 <w:tc><w:p><w:r><w:t>Deep</w:t></w:r></w:p></w:tc>
2978 </w:tr>
2979 </w:tbl>
2980 </w:tc>
2981 </w:tr>
2982 </w:tbl>
2983 </w:body>
2984</w:document>"#;
2985 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2986 let blocks = reader.read_blocks().unwrap();
2987 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2988 match &blocks[0] {
2989 DocumentBlock::Table(outer) => {
2990 let cell0 = &outer.rows[0].cells[0];
2991 assert_eq!(cell0.blocks.len(), 1);
2992 assert!(matches!(&cell0.blocks[0], DocumentBlock::Table(_)));
2993 }
2994 other => panic!("expected Table, got {other:?}"),
2995 }
2996 }
2997
2998 #[test]
2999 fn flat_table_still_works() {
3000 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3002<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3003 <w:body>
3004 <w:tbl>
3005 <w:tr>
3006 <w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>
3007 <w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
3008 </w:tr>
3009 </w:tbl>
3010 </w:body>
3011</w:document>"#;
3012 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3013 let blocks = reader.read_blocks().unwrap();
3014 assert_eq!(blocks.len(), 1);
3015 match &blocks[0] {
3016 DocumentBlock::Table(table) => {
3017 assert_eq!(table.rows.len(), 1);
3018 assert_eq!(table.rows[0].cells.len(), 2);
3019 let cell_text = |cell: &easydoc_core::DocumentTableCell| -> String {
3021 cell.blocks
3022 .iter()
3023 .filter_map(|b| match b {
3024 DocumentBlock::Paragraph(runs) => {
3025 Some(runs.iter().map(|r| r.text.as_str()).collect::<String>())
3026 }
3027 _ => None,
3028 })
3029 .collect()
3030 };
3031 assert_eq!(cell_text(&table.rows[0].cells[0]), "X");
3032 assert_eq!(cell_text(&table.rows[0].cells[1]), "Y");
3033 }
3034 other => panic!("expected Table, got {other:?}"),
3035 }
3036 }
3037
3038 #[test]
3043 fn list_then_hyperlink_then_table() {
3044 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3046<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3047 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3048 <w:body>
3049 <w:p>
3050 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3051 <w:r><w:t>Item</w:t></w:r>
3052 </w:p>
3053 <w:p>
3054 <w:hyperlink r:id="rId7">
3055 <w:r><w:t>Link</w:t></w:r>
3056 </w:hyperlink>
3057 </w:p>
3058 <w:tbl>
3059 <w:tr>
3060 <w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc>
3061 </w:tr>
3062 </w:tbl>
3063 </w:body>
3064</w:document>"#;
3065 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3066 let blocks = reader.read_blocks().unwrap();
3067 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
3068 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
3069 match &blocks[1] {
3070 DocumentBlock::Paragraph(runs) => {
3071 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId7"));
3072 }
3073 other => panic!("expected Paragraph, got {other:?}"),
3074 }
3075 assert!(matches!(&blocks[2], DocumentBlock::Table(_)));
3076 }
3077
3078 fn make_docx_with_numbering(doc_xml: &[u8], numbering_xml: &[u8]) -> Vec<u8> {
3084 use std::io::Write;
3085 let mut buf = Vec::new();
3086 {
3087 let w = std::io::Cursor::new(&mut buf);
3088 let mut zip = zip::ZipWriter::new(w);
3089 let options = zip::write::SimpleFileOptions::default()
3090 .compression_method(zip::CompressionMethod::Stored);
3091
3092 zip.start_file("word/document.xml", options).unwrap();
3093 zip.write_all(doc_xml).unwrap();
3094
3095 zip.start_file("word/numbering.xml", options).unwrap();
3096 zip.write_all(numbering_xml).unwrap();
3097
3098 zip.finish().unwrap();
3099 }
3100 buf
3101 }
3102
3103 fn make_docx_with_rels_and_numbering(
3105 doc_xml: &[u8],
3106 rels_xml: &[u8],
3107 numbering_xml: &[u8],
3108 ) -> Vec<u8> {
3109 use std::io::Write;
3110 let mut buf = Vec::new();
3111 {
3112 let w = std::io::Cursor::new(&mut buf);
3113 let mut zip = zip::ZipWriter::new(w);
3114 let options = zip::write::SimpleFileOptions::default()
3115 .compression_method(zip::CompressionMethod::Stored);
3116
3117 zip.start_file("word/document.xml", options).unwrap();
3118 zip.write_all(doc_xml).unwrap();
3119
3120 zip.start_file("word/_rels/document.xml.rels", options)
3121 .unwrap();
3122 zip.write_all(rels_xml).unwrap();
3123
3124 zip.start_file("word/numbering.xml", options).unwrap();
3125 zip.write_all(numbering_xml).unwrap();
3126
3127 zip.finish().unwrap();
3128 }
3129 buf
3130 }
3131
3132 #[test]
3133 fn e2e_ordered_list_from_numbering_xml() {
3134 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3135<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3136 <w:body>
3137 <w:p>
3138 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3139 <w:r><w:t>First</w:t></w:r>
3140 </w:p>
3141 <w:p>
3142 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3143 <w:r><w:t>Second</w:t></w:r>
3144 </w:p>
3145 <w:p>
3146 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3147 <w:r><w:t>Third</w:t></w:r>
3148 </w:p>
3149 </w:body>
3150</w:document>"#;
3151
3152 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3153<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3154 <w:abstractNum w:abstractNumId="0">
3155 <w:lvl w:ilvl="0">
3156 <w:start w:val="1"/>
3157 <w:numFmt w:val="decimal"/>
3158 <w:lvlText w:val="%1."/>
3159 </w:lvl>
3160 </w:abstractNum>
3161 <w:num w:numId="1">
3162 <w:abstractNumId w:val="0"/>
3163 </w:num>
3164</w:numbering>"#;
3165
3166 let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3167 let tmp = tempfile::NamedTempFile::new().unwrap();
3168 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3169 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3170 let blocks = reader.read_blocks().unwrap();
3171
3172 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3173 match &blocks[0] {
3174 DocumentBlock::List(list) => {
3175 assert!(list.ordered, "list should be ordered (decimal fmt)");
3176 assert_eq!(list.start_number, Some(1));
3177 assert_eq!(list.items.len(), 3);
3178 }
3179 other => panic!("expected List, got {other:?}"),
3180 }
3181 }
3182
3183 #[test]
3184 fn e2e_bullet_list_remains_unordered() {
3185 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3186<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3187 <w:body>
3188 <w:p>
3189 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3190 <w:r><w:t>Bullet A</w:t></w:r>
3191 </w:p>
3192 <w:p>
3193 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3194 <w:r><w:t>Bullet B</w:t></w:r>
3195 </w:p>
3196 </w:body>
3197</w:document>"#;
3198
3199 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3200<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3201 <w:abstractNum w:abstractNumId="0">
3202 <w:lvl w:ilvl="0">
3203 <w:numFmt w:val="bullet"/>
3204 <w:lvlText w:val="•"/>
3205 </w:lvl>
3206 </w:abstractNum>
3207 <w:num w:numId="1">
3208 <w:abstractNumId w:val="0"/>
3209 </w:num>
3210</w:numbering>"#;
3211
3212 let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3213 let tmp = tempfile::NamedTempFile::new().unwrap();
3214 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3215 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3216 let blocks = reader.read_blocks().unwrap();
3217
3218 match &blocks[0] {
3219 DocumentBlock::List(list) => {
3220 assert!(!list.ordered, "bullet list should be unordered");
3221 assert_eq!(list.start_number, None);
3222 assert_eq!(list.items.len(), 2);
3223 }
3224 other => panic!("expected List, got {other:?}"),
3225 }
3226 }
3227
3228 #[test]
3229 fn e2e_numbering_missing_numid_falls_back_to_unordered() {
3230 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3232<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3233 <w:body>
3234 <w:p>
3235 <w:pPr><w:numPr><w:numId w:val="99"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3236 <w:r><w:t>Unknown numId</w:t></w:r>
3237 </w:p>
3238 </w:body>
3239</w:document>"#;
3240
3241 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3242<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3243 <w:abstractNum w:abstractNumId="0">
3244 <w:lvl w:ilvl="0">
3245 <w:start w:val="1"/>
3246 <w:numFmt w:val="decimal"/>
3247 </w:lvl>
3248 </w:abstractNum>
3249 <w:num w:numId="1">
3250 <w:abstractNumId w:val="0"/>
3251 </w:num>
3252</w:numbering>"#;
3253
3254 let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3255 let tmp = tempfile::NamedTempFile::new().unwrap();
3256 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3257 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3258 let blocks = reader.read_blocks().unwrap();
3259
3260 match &blocks[0] {
3261 DocumentBlock::List(list) => {
3262 assert!(!list.ordered, "unknown numId should fallback to unordered");
3263 assert_eq!(list.start_number, None);
3264 }
3265 other => panic!("expected List, got {other:?}"),
3266 }
3267 }
3268
3269 #[test]
3270 fn e2e_no_numbering_xml_falls_back_to_unordered() {
3271 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3273<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3274 <w:body>
3275 <w:p>
3276 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3277 <w:r><w:t>Item</w:t></w:r>
3278 </w:p>
3279 </w:body>
3280</w:document>"#;
3281
3282 let zip_data = make_docx_xml(doc_xml);
3284 let tmp = tempfile::NamedTempFile::new().unwrap();
3285 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3286 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3287 let blocks = reader.read_blocks().unwrap();
3288
3289 match &blocks[0] {
3290 DocumentBlock::List(list) => {
3291 assert!(!list.ordered, "no numbering.xml => unordered fallback");
3292 assert_eq!(list.start_number, None);
3293 }
3294 other => panic!("expected List, got {other:?}"),
3295 }
3296 }
3297
3298 #[test]
3299 fn e2e_ordered_list_with_start_value() {
3300 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3301<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3302 <w:body>
3303 <w:p>
3304 <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3305 <w:r><w:t>Item five</w:t></w:r>
3306 </w:p>
3307 <w:p>
3308 <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3309 <w:r><w:t>Item six</w:t></w:r>
3310 </w:p>
3311 </w:body>
3312</w:document>"#;
3313
3314 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3315<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3316 <w:abstractNum w:abstractNumId="0">
3317 <w:lvl w:ilvl="0">
3318 <w:start w:val="5"/>
3319 <w:numFmt w:val="decimal"/>
3320 <w:lvlText w:val="%1."/>
3321 </w:lvl>
3322 </w:abstractNum>
3323 <w:num w:numId="2">
3324 <w:abstractNumId w:val="0"/>
3325 </w:num>
3326</w:numbering>"#;
3327
3328 let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3329 let tmp = tempfile::NamedTempFile::new().unwrap();
3330 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3331 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3332 let blocks = reader.read_blocks().unwrap();
3333
3334 match &blocks[0] {
3335 DocumentBlock::List(list) => {
3336 assert!(list.ordered);
3337 assert_eq!(list.start_number, Some(5));
3338 }
3339 other => panic!("expected List, got {other:?}"),
3340 }
3341 }
3342
3343 #[test]
3348 fn e2e_hyperlink_resolves_to_url() {
3349 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3350<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3351 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3352 <w:body>
3353 <w:p>
3354 <w:hyperlink r:id="rId10">
3355 <w:r><w:t>Visit example</w:t></w:r>
3356 </w:hyperlink>
3357 </w:p>
3358 </w:body>
3359</w:document>"#;
3360
3361 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3362<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3363 <Relationship Id="rId10" Target="https://example.com"
3364 Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
3365 TargetMode="External"/>
3366</Relationships>"#;
3367
3368 let zip_data = make_docx_with_image(doc_xml, rels_xml, &[]);
3369 let tmp = tempfile::NamedTempFile::new().unwrap();
3370 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3371 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3372 let blocks = reader.read_blocks().unwrap();
3373
3374 match &blocks[0] {
3375 DocumentBlock::Paragraph(runs) => {
3376 assert_eq!(runs.len(), 1);
3377 assert_eq!(runs[0].text, "Visit example");
3378 assert_eq!(
3379 runs[0].hyperlink.as_deref(),
3380 Some("https://example.com"),
3381 "hyperlink should be resolved to URL, not raw rId"
3382 );
3383 }
3384 other => panic!("expected Paragraph, got {other:?}"),
3385 }
3386 }
3387
3388 #[test]
3389 fn e2e_hyperlink_fallback_to_rid_when_no_rels() {
3390 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3392<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3393 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3394 <w:body>
3395 <w:p>
3396 <w:hyperlink r:id="rId5">
3397 <w:r><w:t>No rels</w:t></w:r>
3398 </w:hyperlink>
3399 </w:p>
3400 </w:body>
3401</w:document>"#;
3402
3403 let zip_data = make_docx_xml(doc_xml);
3404 let tmp = tempfile::NamedTempFile::new().unwrap();
3405 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3406 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3407 let blocks = reader.read_blocks().unwrap();
3408
3409 match &blocks[0] {
3410 DocumentBlock::Paragraph(runs) => {
3411 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId5"));
3412 }
3413 other => panic!("expected Paragraph, got {other:?}"),
3414 }
3415 }
3416
3417 #[test]
3418 fn e2e_hyperlink_in_list_items_resolves() {
3419 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3422<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3423 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3424 <w:body>
3425 <w:p>
3426 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3427 <w:r><w:t>See </w:t></w:r>
3428 <w:hyperlink r:id="rId20">
3429 <w:r><w:t>Rust lang</w:t></w:r>
3430 </w:hyperlink>
3431 </w:p>
3432 <w:p>
3433 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3434 <w:r><w:t>Plain item</w:t></w:r>
3435 </w:p>
3436 </w:body>
3437</w:document>"#;
3438
3439 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3440<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3441 <Relationship Id="rId20" Target="https://rust-lang.org"
3442 Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
3443 TargetMode="External"/>
3444</Relationships>"#;
3445
3446 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3447<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3448 <w:abstractNum w:abstractNumId="0">
3449 <w:lvl w:ilvl="0">
3450 <w:start w:val="1"/>
3451 <w:numFmt w:val="decimal"/>
3452 </w:lvl>
3453 </w:abstractNum>
3454 <w:num w:numId="1">
3455 <w:abstractNumId w:val="0"/>
3456 </w:num>
3457</w:numbering>"#;
3458
3459 let zip_data = make_docx_with_rels_and_numbering(doc_xml, rels_xml, numbering_xml);
3460 let tmp = tempfile::NamedTempFile::new().unwrap();
3461 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3462 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3463 let blocks = reader.read_blocks().unwrap();
3464
3465 match &blocks[0] {
3467 DocumentBlock::List(list) => {
3468 assert!(list.ordered, "should be ordered (decimal)");
3469 assert_eq!(list.items.len(), 2);
3470
3471 match &list.items[0].blocks[0] {
3473 DocumentBlock::Paragraph(runs) => {
3474 assert_eq!(runs.len(), 2);
3475 assert_eq!(runs[0].text, "See ");
3476 assert!(runs[0].hyperlink.is_none());
3477 assert_eq!(runs[1].text, "Rust lang");
3478 assert_eq!(runs[1].hyperlink.as_deref(), Some("https://rust-lang.org"),);
3479 }
3480 other => panic!("expected Paragraph, got {other:?}"),
3481 }
3482
3483 match &list.items[1].blocks[0] {
3485 DocumentBlock::Paragraph(runs) => {
3486 assert_eq!(runs[0].text, "Plain item");
3487 assert!(runs[0].hyperlink.is_none());
3488 }
3489 other => panic!("expected Paragraph, got {other:?}"),
3490 }
3491 }
3492 other => panic!("expected List, got {other:?}"),
3493 }
3494 }
3495
3496 #[test]
3501 fn two_level_list_nests_ilvl_1_in_ilvl_0() {
3502 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3504<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3505 <w:body>
3506 <w:p>
3507 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3508 <w:r><w:t>Top A</w:t></w:r>
3509 </w:p>
3510 <w:p>
3511 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3512 <w:r><w:t>Top B</w:t></w:r>
3513 </w:p>
3514 <w:p>
3515 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3516 <w:r><w:t>Nested under B</w:t></w:r>
3517 </w:p>
3518 </w:body>
3519</w:document>"#;
3520 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3521 let blocks = reader.read_blocks().unwrap();
3522 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3523 match &blocks[0] {
3524 DocumentBlock::List(list) => {
3525 assert_eq!(list.items.len(), 2, "items = {:?}", list.items);
3527
3528 assert!(list.items[0].nested.is_none());
3530
3531 let nested = list.items[1]
3533 .nested
3534 .as_ref()
3535 .expect("Top B should have nested list");
3536 assert_eq!(nested.items.len(), 1);
3537 match &nested.items[0].blocks[0] {
3538 DocumentBlock::Paragraph(runs) => {
3539 assert_eq!(runs[0].text, "Nested under B");
3540 }
3541 other => panic!("expected Paragraph, got {other:?}"),
3542 }
3543 }
3544 other => panic!("expected List, got {other:?}"),
3545 }
3546 }
3547
3548 #[test]
3549 fn three_level_list_nests_correctly() {
3550 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3552<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3553 <w:body>
3554 <w:p>
3555 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3556 <w:r><w:t>Level 0</w:t></w:r>
3557 </w:p>
3558 <w:p>
3559 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3560 <w:r><w:t>Level 1</w:t></w:r>
3561 </w:p>
3562 <w:p>
3563 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="2"/></w:numPr></w:pPr>
3564 <w:r><w:t>Level 2</w:t></w:r>
3565 </w:p>
3566 <w:p>
3567 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3568 <w:r><w:t>Second top</w:t></w:r>
3569 </w:p>
3570 </w:body>
3571</w:document>"#;
3572 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3573 let blocks = reader.read_blocks().unwrap();
3574 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3575 match &blocks[0] {
3576 DocumentBlock::List(list) => {
3577 assert_eq!(list.items.len(), 2);
3579
3580 let item0 = &list.items[0];
3582 let nested1 = item0.nested.as_ref().expect("Level 0 should have nested");
3583 assert_eq!(nested1.items.len(), 1);
3584
3585 let nested2 = nested1.items[0]
3587 .nested
3588 .as_ref()
3589 .expect("Level 1 should have nested");
3590 assert_eq!(nested2.items.len(), 1);
3591
3592 match &nested2.items[0].blocks[0] {
3594 DocumentBlock::Paragraph(runs) => {
3595 assert_eq!(runs[0].text, "Level 2");
3596 }
3597 other => panic!("expected Paragraph, got {other:?}"),
3598 }
3599
3600 assert!(list.items[1].nested.is_none());
3602 }
3603 other => panic!("expected List, got {other:?}"),
3604 }
3605 }
3606
3607 #[test]
3608 fn flat_list_with_multiple_ilvl_0() {
3609 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3611<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3612 <w:body>
3613 <w:p>
3614 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3615 <w:r><w:t>A</w:t></w:r>
3616 </w:p>
3617 <w:p>
3618 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3619 <w:r><w:t>B</w:t></w:r>
3620 </w:p>
3621 <w:p>
3622 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3623 <w:r><w:t>C</w:t></w:r>
3624 </w:p>
3625 <w:p>
3626 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3627 <w:r><w:t>D</w:t></w:r>
3628 </w:p>
3629 <w:p>
3630 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3631 <w:r><w:t>E</w:t></w:r>
3632 </w:p>
3633 </w:body>
3634</w:document>"#;
3635 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3636 let blocks = reader.read_blocks().unwrap();
3637 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3638 match &blocks[0] {
3639 DocumentBlock::List(list) => {
3640 assert_eq!(list.items.len(), 5);
3641 for item in &list.items {
3642 assert!(item.nested.is_none(), "flat items should not have nested");
3643 }
3644 let texts: Vec<&str> = list
3646 .items
3647 .iter()
3648 .map(|item| match &item.blocks[0] {
3649 DocumentBlock::Paragraph(runs) => runs[0].text.as_str(),
3650 _ => panic!("expected Paragraph"),
3651 })
3652 .collect();
3653 assert_eq!(texts, vec!["A", "B", "C", "D", "E"]);
3654 }
3655 other => panic!("expected List, got {other:?}"),
3656 }
3657 }
3658
3659 #[test]
3660 fn list_breaks_at_non_list_paragraph() {
3661 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3663<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3664 <w:body>
3665 <w:p>
3666 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3667 <w:r><w:t>List 1 top</w:t></w:r>
3668 </w:p>
3669 <w:p>
3670 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3671 <w:r><w:t>List 1 nested</w:t></w:r>
3672 </w:p>
3673 <w:p>
3674 <w:r><w:t>Separator paragraph</w:t></w:r>
3675 </w:p>
3676 <w:p>
3677 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3678 <w:r><w:t>List 2 top</w:t></w:r>
3679 </w:p>
3680 </w:body>
3681</w:document>"#;
3682 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3683 let blocks = reader.read_blocks().unwrap();
3684 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
3686
3687 match &blocks[0] {
3689 DocumentBlock::List(list) => {
3690 assert_eq!(list.items.len(), 1);
3691 let nested = list.items[0]
3692 .nested
3693 .as_ref()
3694 .expect("first list item should have nested");
3695 assert_eq!(nested.items.len(), 1);
3696 }
3697 other => panic!("expected List, got {other:?}"),
3698 }
3699
3700 match &blocks[1] {
3702 DocumentBlock::Paragraph(runs) => {
3703 assert_eq!(runs[0].text, "Separator paragraph");
3704 }
3705 other => panic!("expected Paragraph, got {other:?}"),
3706 }
3707
3708 match &blocks[2] {
3710 DocumentBlock::List(list) => {
3711 assert_eq!(list.items.len(), 1);
3712 assert!(list.items[0].nested.is_none());
3713 }
3714 other => panic!("expected List, got {other:?}"),
3715 }
3716 }
3717
3718 #[test]
3719 fn ilvl_decrease_creates_separate_branch() {
3720 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3722<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3723 <w:body>
3724 <w:p>
3725 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3726 <w:r><w:t>Branch A</w:t></w:r>
3727 </w:p>
3728 <w:p>
3729 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3730 <w:r><w:t>Branch A child</w:t></w:r>
3731 </w:p>
3732 <w:p>
3733 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3734 <w:r><w:t>Branch B</w:t></w:r>
3735 </w:p>
3736 </w:body>
3737</w:document>"#;
3738 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3739 let blocks = reader.read_blocks().unwrap();
3740 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3741 match &blocks[0] {
3742 DocumentBlock::List(list) => {
3743 assert_eq!(list.items.len(), 2);
3744
3745 let nested = list.items[0]
3747 .nested
3748 .as_ref()
3749 .expect("Branch A should have nested");
3750 assert_eq!(nested.items.len(), 1);
3751 match &nested.items[0].blocks[0] {
3752 DocumentBlock::Paragraph(runs) => {
3753 assert_eq!(runs[0].text, "Branch A child");
3754 }
3755 other => panic!("expected Paragraph, got {other:?}"),
3756 }
3757
3758 assert!(list.items[1].nested.is_none());
3760 match &list.items[1].blocks[0] {
3761 DocumentBlock::Paragraph(runs) => {
3762 assert_eq!(runs[0].text, "Branch B");
3763 }
3764 other => panic!("expected Paragraph, got {other:?}"),
3765 }
3766 }
3767 other => panic!("expected List, got {other:?}"),
3768 }
3769 }
3770
3771 #[test]
3772 fn multiple_siblings_at_nested_level() {
3773 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3775<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3776 <w:body>
3777 <w:p>
3778 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3779 <w:r><w:t>Parent</w:t></w:r>
3780 </w:p>
3781 <w:p>
3782 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3783 <w:r><w:t>Child 1</w:t></w:r>
3784 </w:p>
3785 <w:p>
3786 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3787 <w:r><w:t>Child 2</w:t></w:r>
3788 </w:p>
3789 <w:p>
3790 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3791 <w:r><w:t>Sibling</w:t></w:r>
3792 </w:p>
3793 </w:body>
3794</w:document>"#;
3795 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3796 let blocks = reader.read_blocks().unwrap();
3797 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3798 match &blocks[0] {
3799 DocumentBlock::List(list) => {
3800 assert_eq!(list.items.len(), 2);
3801
3802 let nested = list.items[0]
3804 .nested
3805 .as_ref()
3806 .expect("Parent should have nested");
3807 assert_eq!(nested.items.len(), 2);
3808
3809 match &nested.items[0].blocks[0] {
3810 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Child 1"),
3811 other => panic!("expected Paragraph, got {other:?}"),
3812 }
3813 match &nested.items[1].blocks[0] {
3814 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Child 2"),
3815 other => panic!("expected Paragraph, got {other:?}"),
3816 }
3817
3818 assert!(list.items[1].nested.is_none());
3820 }
3821 other => panic!("expected List, got {other:?}"),
3822 }
3823 }
3824}