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 #[allow(clippy::match_same_arms)]
203 match event {
204 DocumentEvent::Heading { level, runs } => {
205 self.0.push(DocumentBlock::Heading {
206 level: *level,
207 runs: runs.clone(),
208 });
209 }
210 DocumentEvent::Paragraph(runs) => {
211 self.0.push(DocumentBlock::Paragraph(runs.clone()));
212 }
213 DocumentEvent::Table(table) => {
214 self.0.push(DocumentBlock::Table(table.clone()));
215 }
216 DocumentEvent::List(list) => {
217 self.0.push(DocumentBlock::List(list.clone()));
218 }
219 DocumentEvent::Image(image) => {
220 self.0.push(DocumentBlock::Image(image.clone()));
221 }
222 DocumentEvent::PageBreak => {
223 self.0.push(DocumentBlock::PageBreak);
224 }
225 DocumentEvent::ColumnBreak => {
226 self.0.push(DocumentBlock::ColumnBreak);
227 }
228 DocumentEvent::CodeBlock { language, code } => {
229 self.0.push(DocumentBlock::CodeBlock {
230 language: language.clone(),
231 code: code.clone(),
232 });
233 }
234 DocumentEvent::Section { section_type } => {
235 self.0.push(DocumentBlock::Section {
236 blocks: Vec::new(),
237 section_type: section_type.clone(),
238 });
239 }
240 DocumentEvent::DocumentStart | DocumentEvent::DocumentEnd => {}
241 _ => {}
243 }
244 Ok(())
245 }
246
247 fn push_block(&mut self, block: DocumentBlock) {
248 self.0.push(block);
249 }
250}
251
252fn flush_paragraph_runs(sink: &mut dyn ParseSink, stack: &mut [ParseState]) -> Result<()> {
256 if let Some(ParseState::Paragraph { runs, .. }) = stack.last_mut()
257 && !runs.is_empty()
258 {
259 let taken = std::mem::take(runs);
260 sink.on_event(&DocumentEvent::Paragraph(taken))?;
261 }
262 Ok(())
263}
264
265fn flush_list(
280 sink: &mut dyn ParseSink,
281 list_items: &mut Vec<(DocumentListItem, u8)>,
282 first_num_id: &mut Option<u32>,
283 first_ilvl: &mut u8,
284 numbering: Option<&super::numbering::Numbering>,
285 relationships: Option<&Relationships>,
286 ssrf: &SsrfGuard,
287) -> Result<()> {
288 if !list_items.is_empty() {
289 resolve_hyperlinks_in_flat_items(list_items, relationships, ssrf);
291
292 let (ordered, start_number) = if let (Some(num_id), Some(num)) = (*first_num_id, numbering)
294 {
295 match num.lookup(num_id, *first_ilvl) {
296 Some(level) => {
297 let start = if level.ordered { level.start } else { None };
298 (level.ordered, start)
299 }
300 None => (false, None),
301 }
302 } else {
303 (false, None)
304 };
305
306 let flat = std::mem::take(list_items);
307 let items = build_nested_items(flat);
308 sink.push_block(DocumentBlock::List(DocumentList {
309 ordered,
310 start_number,
311 items,
312 }));
313 *first_num_id = None;
314 *first_ilvl = 0;
315 }
316 Ok(())
317}
318
319fn build_nested_items(flat: Vec<(DocumentListItem, u8)>) -> Vec<DocumentListItem> {
328 let mut items: Vec<DocumentListItem> = Vec::new();
329
330 for (new_item, ilvl) in flat {
331 if ilvl == 0 {
332 items.push(new_item);
333 } else {
334 if let Some(parent) = items.last_mut() {
336 attach_to_nested(parent, new_item, ilvl);
337 } else {
338 items.push(new_item);
340 }
341 }
342 }
343
344 items
345}
346
347fn attach_to_nested(parent: &mut DocumentListItem, new_item: DocumentListItem, ilvl: u8) {
353 if ilvl == 1 {
354 let nested = parent
356 .nested
357 .get_or_insert_with(|| Box::new(DocumentList::default()));
358 nested.items.push(new_item);
359 } else {
360 let nested = parent
362 .nested
363 .get_or_insert_with(|| Box::new(DocumentList::default()));
364 if let Some(last) = nested.items.last_mut() {
365 attach_to_nested(last, new_item, ilvl - 1);
366 } else {
367 nested.items.push(new_item);
370 }
371 }
372}
373
374fn resolve_vmerge_row_spans(table: &mut easydoc_core::DocumentTable) {
382 for row_idx in 0..table.rows.len() {
383 for col_idx in 0..table.rows[row_idx].cells.len() {
384 if table.rows[row_idx].cells[col_idx].row_span == 0 {
385 continue; }
387 let mut span = 1;
390 for below in &table.rows[row_idx + 1..] {
391 let is_continue = below
392 .cells
393 .iter()
394 .any(|c| c.row_span == 0 && c.blocks.is_empty());
395 if is_continue {
396 span += 1;
397 } else {
398 break;
399 }
400 }
401 if span > 1 {
402 table.rows[row_idx].cells[col_idx].row_span = span;
403 }
404 }
405 }
406}
407
408fn resolve_hyperlinks_in_items(
410 items: &mut [DocumentListItem],
411 relationships: Option<&Relationships>,
412 ssrf: &SsrfGuard,
413) {
414 let Some(rels) = relationships else { return };
415 for item in items.iter_mut() {
416 resolve_hyperlinks_in_blocks(&mut item.blocks, rels, ssrf);
417 if let Some(nested) = item.nested.as_mut() {
419 resolve_hyperlinks_in_items(&mut nested.items, Some(rels), ssrf);
420 }
421 }
422}
423
424fn resolve_hyperlinks_in_flat_items(
426 items: &mut [(DocumentListItem, u8)],
427 relationships: Option<&Relationships>,
428 ssrf: &SsrfGuard,
429) {
430 let Some(rels) = relationships else { return };
431 for (item, _ilvl) in items.iter_mut() {
432 resolve_hyperlinks_in_blocks(&mut item.blocks, rels, ssrf);
433 }
434}
435
436fn resolve_hyperlinks_in_blocks(
438 blocks: &mut [DocumentBlock],
439 rels: &Relationships,
440 ssrf: &SsrfGuard,
441) {
442 for block in blocks {
443 match block {
444 DocumentBlock::Paragraph(runs) | DocumentBlock::Heading { runs, .. } => {
445 resolve_hyperlinks_in_runs(runs, rels, ssrf);
446 }
447 DocumentBlock::List(list) => {
448 resolve_hyperlinks_in_items(&mut list.items, Some(rels), ssrf);
449 }
450 DocumentBlock::Table(table) => {
451 for row in &mut table.rows {
452 for cell in &mut row.cells {
453 resolve_hyperlinks_in_blocks(&mut cell.blocks, rels, ssrf);
454 }
455 }
456 }
457 _ => {}
458 }
459 }
460}
461
462fn resolve_hyperlinks_in_runs(
469 runs: &mut [DocumentTextRun],
470 rels: &Relationships,
471 ssrf: &SsrfGuard,
472) {
473 for run in runs.iter_mut() {
474 if let Some(rid) = run.hyperlink.take() {
475 match rels.resolve_hyperlink(&rid) {
476 Some(resolved_url) => {
477 if ssrf.check_url(resolved_url).is_ok() {
480 run.hyperlink = Some(resolved_url.to_owned());
481 }
482 }
484 None => {
485 run.hyperlink = Some(rid);
487 }
488 }
489 }
490 }
491}
492
493fn extract_rid(tag: &quick_xml::events::BytesStart) -> Option<String> {
495 for attr in tag.attributes().flatten() {
496 if attr.key.as_ref() == R_ID {
497 return attr
498 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
499 .ok()
500 .map(std::borrow::Cow::into_owned);
501 }
502 }
503 None
504}
505
506pub struct DocxSaxReader<R: Read> {
531 reader: XmlReader<BufReader<R>>,
532 archive: Option<zip::ZipArchive<File>>,
534 relationships: Option<Relationships>,
536 numbering: Option<super::numbering::Numbering>,
538 security: SecurityPolicy,
540}
541
542impl DocxSaxReader<std::io::Cursor<Vec<u8>>> {
543 pub fn from_path(path: &Path) -> Result<Self> {
554 Self::from_path_with_security(path, SecurityPolicy::new())
555 }
556
557 pub fn from_path_with_security(path: &Path, security: SecurityPolicy) -> Result<Self> {
566 let file = File::open(path)?;
567 let mut archive = zip::ZipArchive::new(file)?;
568
569 security
571 .limits
572 .validate_archive(&mut archive)
573 .map_err(|msg| DocError::Format(format!("security: {msg}")))?;
574
575 let entry_name = if archive.index_for_name("word/document.xml").is_some() {
577 "word/document.xml".to_owned()
578 } else {
579 find_word_document_xml(&mut archive)?
580 };
581
582 let relationships = if archive
584 .index_for_name("word/_rels/document.xml.rels")
585 .is_some()
586 {
587 let rels_bytes = {
588 let mut entry = archive.by_name("word/_rels/document.xml.rels")?;
589 let mut buf = Vec::new();
590 std::io::Read::read_to_end(&mut entry, &mut buf)?;
591 buf
592 };
593 let rels_xml = String::from_utf8(rels_bytes)
594 .map_err(|e| DocError::Format(format!("rels XML not valid UTF-8: {e}")))?;
595 Some(Relationships::parse(&rels_xml)?)
596 } else {
597 None
598 };
599
600 let numbering = if archive.index_for_name("word/numbering.xml").is_some() {
602 let num_bytes = {
603 let mut entry = archive.by_name("word/numbering.xml")?;
604 let mut buf = Vec::new();
605 std::io::Read::read_to_end(&mut entry, &mut buf)?;
606 buf
607 };
608 match String::from_utf8(num_bytes) {
609 Ok(xml) => super::numbering::Numbering::parse(&xml).ok(),
610 Err(_) => None,
611 }
612 } else {
613 None
614 };
615
616 let xml_bytes = {
618 let mut entry = archive.by_name(&entry_name)?;
619 let mut buf = Vec::new();
620 std::io::Read::read_to_end(&mut entry, &mut buf)?;
621 buf
622 };
623
624 let buf_reader = BufReader::new(std::io::Cursor::new(xml_bytes));
625 let mut reader = XmlReader::from_reader(buf_reader);
626 reader.config_mut().trim_text(false);
627
628 Ok(Self {
629 reader,
630 archive: Some(archive),
631 relationships,
632 numbering,
633 security,
634 })
635 }
636}
637
638impl<R: Read> DocxSaxReader<R> {
639 pub fn from_reader(source: R) -> Self {
643 let buf_reader = BufReader::new(source);
644 let mut reader = XmlReader::from_reader(buf_reader);
645 reader.config_mut().trim_text(false);
646 Self {
647 reader,
648 archive: None,
649 relationships: None,
650 numbering: None,
651 security: SecurityPolicy::new(),
652 }
653 }
654
655 pub fn read_events(&mut self, sink: &mut dyn EventSink) -> Result<()> {
666 let mut adapter = EventSinkAdapter(sink);
667 self.parse_with_sink(&mut adapter)
668 }
669
670 pub fn read_blocks(&mut self) -> Result<Vec<DocumentBlock>> {
682 let mut collector = BlockCollector(Vec::new());
683 self.parse_with_sink(&mut collector)?;
684 Ok(collector.0)
685 }
686
687 fn parse_with_sink(&mut self, sink: &mut dyn ParseSink) -> Result<()> {
690 sink.on_event(&DocumentEvent::DocumentStart)?;
691
692 let mut state_stack: Vec<ParseState> = vec![ParseState::Document];
693 let mut buf = Vec::new();
694
695 let mut list_items: Vec<(DocumentListItem, u8)> = Vec::new();
700 let mut first_list_num_id: Option<u32> = None;
703 let mut first_list_ilvl: u8 = 0;
704
705 let mut in_math = false;
707 let mut math_is_para = false;
708 let mut math_depth: u32 = 0;
709 let mut math_xml_buf = String::new();
710
711 loop {
712 let event = self
713 .reader
714 .read_event_into(&mut buf)
715 .map_err(|e| DocError::Format(format!("XML parse error: {e}")))?;
716
717 if in_math {
721 match &event {
722 Event::Start(start) => {
723 let name = start.name();
724 let name_bytes = name.as_ref();
725 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
726 math_depth += 1;
727 }
728 math_xml_buf.push('<');
729 math_xml_buf.push_str(std::str::from_utf8(start.as_ref()).unwrap_or(""));
730 math_xml_buf.push('>');
731 }
732 Event::End(end) => {
733 let name = end.name();
734 let name_bytes = name.as_ref();
735
736 math_xml_buf.push_str("</");
738 math_xml_buf.push_str(std::str::from_utf8(name_bytes).unwrap_or(""));
739 math_xml_buf.push('>');
740
741 let is_closing_root = if math_is_para {
744 name_bytes == M_OMATHPARA
745 } else {
746 name_bytes == M_OMATH
747 };
748
749 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
750 math_depth = math_depth.saturating_sub(1);
751 }
752
753 if is_closing_root && math_depth == 0 {
754 sink.push_block(DocumentBlock::Math {
755 omml: Some(std::mem::take(&mut math_xml_buf)),
756 latex: None,
757 display: math_is_para,
758 });
759 in_math = false;
760 }
761 }
762 Event::Empty(empty) => {
763 math_xml_buf.push('<');
764 math_xml_buf.push_str(std::str::from_utf8(empty.as_ref()).unwrap_or(""));
765 math_xml_buf.push_str("/>");
766 }
767 Event::Text(text) => {
768 math_xml_buf.push_str(std::str::from_utf8(text.as_ref()).unwrap_or(""));
769 }
770 _ => {}
771 }
772 buf.clear();
773 continue;
774 }
775
776 match event {
778 Event::Eof => break,
779 Event::Start(ref start) => {
780 let name = start.name();
781 let name_bytes = name.as_ref();
782 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
783 flush_paragraph_runs(sink, &mut state_stack)?;
786 in_math = true;
787 math_is_para = name_bytes == M_OMATHPARA;
788 math_depth = 1;
789 math_xml_buf.clear();
790 math_xml_buf.push('<');
791 math_xml_buf.push_str(std::str::from_utf8(start.as_ref()).unwrap_or(""));
792 math_xml_buf.push('>');
793 } else {
794 handle_start(start, &mut state_stack)?;
795 }
796 }
797 Event::Empty(ref empty) => {
798 let name = empty.name();
799 let name_bytes = name.as_ref();
800 if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
801 flush_paragraph_runs(sink, &mut state_stack)?;
803 let display = name_bytes == M_OMATHPARA;
804 let mut xml = String::from("<");
805 xml.push_str(std::str::from_utf8(empty.as_ref()).unwrap_or(""));
806 xml.push_str("/>");
807 sink.push_block(DocumentBlock::Math {
808 omml: Some(xml),
809 latex: None,
810 display,
811 });
812 } else {
813 handle_empty(empty, sink, &mut state_stack)?;
814 }
815 }
816 Event::Text(ref text) => {
817 handle_text(text, &mut state_stack)?;
818 }
819 Event::End(ref end) => {
820 handle_end(
821 end,
822 sink,
823 &mut state_stack,
824 &mut ParseContext {
825 archive: self.archive.as_mut(),
826 relationships: self.relationships.as_ref(),
827 numbering: self.numbering.as_ref(),
828 list_items: &mut list_items,
829 first_list_num_id: &mut first_list_num_id,
830 first_list_ilvl: &mut first_list_ilvl,
831 ssrf: &self.security.ssrf,
832 },
833 )?;
834 }
835 _ => {}
836 }
837
838 buf.clear();
839 }
840
841 flush_list(
843 sink,
844 &mut list_items,
845 &mut first_list_num_id,
846 &mut first_list_ilvl,
847 self.numbering.as_ref(),
848 self.relationships.as_ref(),
849 &self.security.ssrf,
850 )?;
851
852 sink.on_event(&DocumentEvent::DocumentEnd)?;
853 sink.on_complete();
854 Ok(())
855 }
856}
857
858fn handle_start(start: &quick_xml::events::BytesStart, stack: &mut Vec<ParseState>) -> Result<()> {
863 let name = start.name();
864 let local = name.as_ref();
865
866 match local {
867 W_P => {
868 if !inside_table(stack) {
872 stack.push(ParseState::Paragraph {
873 runs: Vec::new(),
874 heading_level: None,
875 in_ppr: false,
876 in_run: false,
877 in_rpr: false,
878 run_bold: false,
879 run_italic: false,
880 run_strike: false,
881 text_buf: String::new(),
882 in_text: false,
883 preserve_space: false,
884 has_num_pr: false,
885 num_id: None,
886 ilvl: None,
887 in_hyperlink: false,
888 hyperlink_rid: None,
889 });
890 }
891 }
892 W_TBL => {
893 stack.push(ParseState::Table {
894 rows: Vec::new(),
895 current_row: None,
896 });
897 }
898 W_DRAWING => {
899 stack.push(ParseState::Drawing {
900 pending_rid: None,
901 pending_alt: None,
902 });
903 }
904 W_PPR => {
905 if let Some(ParseState::Paragraph { in_ppr, .. }) = stack.last_mut() {
906 *in_ppr = true;
907 }
908 }
909 W_NUMPR => {
910 if let Some(ParseState::Paragraph { has_num_pr, .. }) = stack.last_mut() {
912 *has_num_pr = true;
913 }
914 }
915 W_NUMID => {
916 if let Some(ParseState::Paragraph { num_id, .. }) = stack.last_mut() {
917 *num_id = extract_val(start).and_then(|v| v.parse::<u32>().ok());
918 }
919 }
920 W_ILVL => {
921 if let Some(ParseState::Paragraph { ilvl, .. }) = stack.last_mut() {
922 *ilvl = extract_val(start).and_then(|v| v.parse::<u8>().ok());
923 }
924 }
925 W_HYPERLINK => {
926 if let Some(ParseState::Paragraph {
929 in_hyperlink,
930 hyperlink_rid,
931 ..
932 }) = stack.last_mut()
933 {
934 *in_hyperlink = true;
935 *hyperlink_rid = extract_rid(start);
936 }
937 }
938 W_PSTYLE => {
939 if let Some(ParseState::Paragraph { heading_level, .. }) = stack.last_mut() {
940 *heading_level = extract_val(start).and_then(|v| parse_heading_level(&v));
941 }
942 }
943 W_R => {
944 if let Some(ParseState::Paragraph {
945 in_run,
946 run_bold,
947 run_italic,
948 run_strike,
949 ..
950 }) = stack.last_mut()
951 {
952 *in_run = true;
953 *run_bold = false;
954 *run_italic = false;
955 *run_strike = false;
956 }
957 }
958 W_RPR => {
959 if let Some(ParseState::Paragraph { in_rpr, .. }) = stack.last_mut() {
960 *in_rpr = true;
961 }
962 }
963 W_B => {
964 if let Some(ParseState::Paragraph {
965 in_rpr, run_bold, ..
966 }) = stack.last_mut()
967 && *in_rpr
968 {
969 *run_bold = extract_bool_attr(start).unwrap_or(true);
970 }
971 }
972 W_I => {
973 if let Some(ParseState::Paragraph {
974 in_rpr, run_italic, ..
975 }) = stack.last_mut()
976 && *in_rpr
977 {
978 *run_italic = extract_bool_attr(start).unwrap_or(true);
979 }
980 }
981 W_STRIKE => {
982 if let Some(ParseState::Paragraph {
983 in_rpr, run_strike, ..
984 }) = stack.last_mut()
985 && *in_rpr
986 {
987 *run_strike = extract_bool_attr(start).unwrap_or(true);
988 }
989 }
990 W_T => {
991 if let Some(ParseState::Paragraph {
992 in_text,
993 preserve_space,
994 ..
995 }) = stack.last_mut()
996 {
997 *in_text = true;
998 *preserve_space = has_preserve_space(start);
999 }
1000 }
1001 W_TR => {
1002 if let Some(ParseState::Table { current_row, .. }) = stack.last_mut() {
1003 *current_row = Some(TableRowBuilder { cells: Vec::new() });
1004 }
1005 }
1006 W_TC => {
1007 if let Some(ParseState::Table {
1008 current_row: Some(row),
1009 ..
1010 }) = stack.last_mut()
1011 {
1012 row.cells.push(TableCellBuilder {
1013 text: String::new(),
1014 column_span: 1,
1015 row_span: 1,
1016 v_merge: None,
1017 in_tcpr: false,
1018 blocks: Vec::new(),
1019 });
1020 }
1021 }
1022 W_TCPR => {
1023 if let Some(ParseState::Table {
1024 current_row: Some(row),
1025 ..
1026 }) = stack.last_mut()
1027 && let Some(cell) = row.cells.last_mut()
1028 {
1029 cell.in_tcpr = true;
1030 }
1031 }
1032 _ => {}
1033 }
1034
1035 Ok(())
1036}
1037
1038fn handle_empty(
1039 empty: &quick_xml::events::BytesStart,
1040 sink: &mut dyn ParseSink,
1041 stack: &mut [ParseState],
1042) -> Result<()> {
1043 let name = empty.name();
1044 let local = name.as_ref();
1045
1046 match local {
1047 W_BR => {
1048 if br_is_page_break(empty) {
1049 sink.on_event(&DocumentEvent::PageBreak)?;
1050 }
1051 }
1052 W_NUMID => {
1053 if let Some(ParseState::Paragraph { num_id, .. }) = stack.last_mut() {
1054 *num_id = extract_val(empty).and_then(|v| v.parse::<u32>().ok());
1055 }
1056 }
1057 W_ILVL => {
1058 if let Some(ParseState::Paragraph { ilvl, .. }) = stack.last_mut() {
1059 *ilvl = extract_val(empty).and_then(|v| v.parse::<u8>().ok());
1060 }
1061 }
1062 W_PSTYLE => {
1063 if let Some(ParseState::Paragraph { heading_level, .. }) = stack.last_mut() {
1064 *heading_level = extract_val(empty).and_then(|v| parse_heading_level(&v));
1065 }
1066 }
1067 W_B => {
1068 if let Some(ParseState::Paragraph {
1069 in_rpr, run_bold, ..
1070 }) = stack.last_mut()
1071 && *in_rpr
1072 {
1073 *run_bold = extract_bool_attr(empty).unwrap_or(true);
1074 }
1075 }
1076 W_I => {
1077 if let Some(ParseState::Paragraph {
1078 in_rpr, run_italic, ..
1079 }) = stack.last_mut()
1080 && *in_rpr
1081 {
1082 *run_italic = extract_bool_attr(empty).unwrap_or(true);
1083 }
1084 }
1085 W_STRIKE => {
1086 if let Some(ParseState::Paragraph {
1087 in_rpr, run_strike, ..
1088 }) = stack.last_mut()
1089 && *in_rpr
1090 {
1091 *run_strike = extract_bool_attr(empty).unwrap_or(true);
1092 }
1093 }
1094 A_BLIP => {
1095 if let Some(ParseState::Drawing { pending_rid, .. }) = stack.last_mut() {
1096 for attr in empty.attributes().flatten() {
1097 if attr.key.as_ref() == R_EMBED {
1098 *pending_rid = attr
1099 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1100 .ok()
1101 .map(Cow::into_owned);
1102 break;
1103 }
1104 }
1105 }
1106 }
1107 W_GRIDSPAN => {
1108 if let Some(ParseState::Table {
1110 current_row: Some(row),
1111 ..
1112 }) = stack.last_mut()
1113 && let Some(cell) = row.cells.last_mut()
1114 && cell.in_tcpr
1115 && let Some(val) = extract_val(empty)
1116 && let Ok(n) = val.parse::<u32>()
1117 && n > 1
1118 {
1119 cell.column_span = n;
1120 }
1121 }
1122 W_VMERGE => {
1123 if let Some(ParseState::Table {
1127 current_row: Some(row),
1128 ..
1129 }) = stack.last_mut()
1130 && let Some(cell) = row.cells.last_mut()
1131 && cell.in_tcpr
1132 {
1133 if let Some("restart") = extract_val(empty).as_deref() {
1134 cell.v_merge = Some(VMerge::Restart);
1135 cell.row_span = 1;
1136 } else {
1137 cell.v_merge = Some(VMerge::Continue);
1139 cell.row_span = 0;
1140 }
1141 }
1142 }
1143 WP_DOC_PR => {
1144 if let Some(ParseState::Drawing { pending_alt, .. }) = stack.last_mut() {
1145 for attr in empty.attributes().flatten() {
1146 if attr.key.as_ref() == b"descr" {
1147 let val = attr
1148 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1149 .ok()
1150 .map(Cow::into_owned);
1151 if val.as_deref().is_some_and(|s| !s.is_empty()) {
1152 *pending_alt = val;
1153 }
1154 break;
1155 }
1156 }
1157 }
1158 }
1159 _ => {}
1160 }
1161
1162 Ok(())
1163}
1164
1165fn handle_text(text: &quick_xml::events::BytesText, stack: &mut [ParseState]) -> Result<()> {
1166 let decoded = std::str::from_utf8(text.as_ref()).unwrap_or("").to_owned();
1169 if let Some(state) = stack.last_mut() {
1170 match state {
1171 ParseState::Paragraph {
1172 in_text: true,
1173 text_buf,
1174 ..
1175 } => {
1176 text_buf.push_str(&decoded);
1177 }
1178 ParseState::Table {
1179 current_row: Some(row),
1180 ..
1181 } => {
1182 if let Some(cell) = row.cells.last_mut() {
1183 cell.text.push_str(&decoded);
1184 }
1185 }
1186 _ => {}
1187 }
1188 }
1189 Ok(())
1190}
1191
1192struct ParseContext<'a> {
1194 archive: Option<&'a mut zip::ZipArchive<File>>,
1195 relationships: Option<&'a Relationships>,
1196 numbering: Option<&'a super::numbering::Numbering>,
1197 list_items: &'a mut Vec<(DocumentListItem, u8)>,
1200 first_list_num_id: &'a mut Option<u32>,
1201 first_list_ilvl: &'a mut u8,
1202 ssrf: &'a SsrfGuard,
1204}
1205
1206fn handle_end(
1207 end: &quick_xml::events::BytesEnd,
1208 sink: &mut dyn ParseSink,
1209 stack: &mut Vec<ParseState>,
1210 ctx: &mut ParseContext<'_>,
1211) -> Result<()> {
1212 let name = end.name();
1213 let local = name.as_ref();
1214
1215 match local {
1216 W_P => {
1217 if matches!(stack.last(), Some(ParseState::Paragraph { .. }))
1220 && let Some(ParseState::Paragraph {
1221 runs,
1222 heading_level,
1223 has_num_pr,
1224 num_id,
1225 ilvl,
1226 ..
1227 }) = stack.pop()
1228 {
1229 if let Some(level) = heading_level {
1230 flush_list(
1232 sink,
1233 ctx.list_items,
1234 ctx.first_list_num_id,
1235 ctx.first_list_ilvl,
1236 ctx.numbering,
1237 ctx.relationships,
1238 ctx.ssrf,
1239 )?;
1240 sink.on_event(&DocumentEvent::Heading { level, runs })?;
1241 } else if has_num_pr {
1242 let item_ilvl = ilvl.unwrap_or(0);
1245 if ctx.list_items.is_empty() {
1246 *ctx.first_list_num_id = num_id;
1247 *ctx.first_list_ilvl = item_ilvl;
1248 }
1249 if !runs.is_empty() {
1250 ctx.list_items.push((
1251 DocumentListItem {
1252 blocks: vec![DocumentBlock::Paragraph(runs)],
1253 nested: None,
1254 },
1255 item_ilvl,
1256 ));
1257 }
1258 } else {
1259 flush_list(
1261 sink,
1262 ctx.list_items,
1263 ctx.first_list_num_id,
1264 ctx.first_list_ilvl,
1265 ctx.numbering,
1266 ctx.relationships,
1267 ctx.ssrf,
1268 )?;
1269 if !runs.is_empty() {
1270 sink.on_event(&DocumentEvent::Paragraph(runs))?;
1272 }
1273 }
1274 }
1275 }
1276 W_R => {
1277 if let Some(ParseState::Paragraph {
1278 in_run,
1279 text_buf,
1280 run_bold,
1281 run_italic,
1282 run_strike,
1283 runs,
1284 in_hyperlink,
1285 hyperlink_rid,
1286 ..
1287 }) = stack.last_mut()
1288 {
1289 if *in_run && !text_buf.is_empty() {
1290 let hyperlink = if *in_hyperlink {
1291 hyperlink_rid.as_ref().map(|rid| {
1292 match ctx
1295 .relationships
1296 .and_then(|rels| rels.resolve_hyperlink(rid))
1297 {
1298 Some(resolved_url) => {
1299 if ctx.ssrf.check_url(resolved_url).is_ok() {
1301 resolved_url.to_owned()
1302 } else {
1303 rid.clone()
1305 }
1306 }
1307 None => rid.clone(),
1308 }
1309 })
1310 } else {
1311 None
1312 };
1313 runs.push(DocumentTextRun {
1314 text: std::mem::take(text_buf),
1315 bold: *run_bold,
1316 italic: *run_italic,
1317 strikethrough: *run_strike,
1318 hyperlink,
1319 });
1320 }
1321 *in_run = false;
1322 *run_bold = false;
1323 *run_italic = false;
1324 *run_strike = false;
1325 }
1326 }
1327 W_HYPERLINK => {
1328 if let Some(ParseState::Paragraph {
1331 in_hyperlink,
1332 hyperlink_rid,
1333 ..
1334 }) = stack.last_mut()
1335 {
1336 *in_hyperlink = false;
1337 *hyperlink_rid = None;
1338 }
1339 }
1340 W_T => {
1341 if let Some(ParseState::Paragraph { in_text, .. }) = stack.last_mut() {
1342 *in_text = false;
1343 }
1344 }
1345 W_PPR => {
1346 if let Some(ParseState::Paragraph { in_ppr, .. }) = stack.last_mut() {
1347 *in_ppr = false;
1348 }
1349 }
1350 W_RPR => {
1351 if let Some(ParseState::Paragraph { in_rpr, .. }) = stack.last_mut() {
1352 *in_rpr = false;
1353 }
1354 }
1355 W_TCPR => {
1356 if let Some(ParseState::Table {
1357 current_row: Some(row),
1358 ..
1359 }) = stack.last_mut()
1360 && let Some(cell) = row.cells.last_mut()
1361 {
1362 cell.in_tcpr = false;
1363 }
1364 }
1365 W_TBL => {
1366 if let Some(ParseState::Table { rows, .. }) = stack.pop() {
1367 let mut table = easydoc_core::DocumentTable { rows };
1368 resolve_vmerge_row_spans(&mut table);
1369 if inside_table(stack) {
1370 if let Some(ParseState::Table {
1372 current_row: Some(row),
1373 ..
1374 }) = stack.last_mut()
1375 && let Some(cell) = row.cells.last_mut()
1376 {
1377 cell.blocks.push(DocumentBlock::Table(table));
1378 }
1379 } else {
1380 flush_list(
1382 sink,
1383 ctx.list_items,
1384 ctx.first_list_num_id,
1385 ctx.first_list_ilvl,
1386 ctx.numbering,
1387 ctx.relationships,
1388 ctx.ssrf,
1389 )?;
1390 sink.on_event(&DocumentEvent::Table(table))?;
1391 }
1392 }
1393 }
1394 W_TR => {
1395 if let Some(ParseState::Table {
1396 current_row, rows, ..
1397 }) = stack.last_mut()
1398 && let Some(row_builder) = current_row.take()
1399 {
1400 let cells = row_builder
1401 .cells
1402 .into_iter()
1403 .map(|c| {
1404 let mut blocks = Vec::new();
1405 let trimmed = c.text.trim().to_owned();
1406 if !trimmed.is_empty() {
1407 blocks.push(easydoc_core::DocumentBlock::Paragraph(vec![
1408 DocumentTextRun {
1409 text: trimmed,
1410 ..DocumentTextRun::default()
1411 },
1412 ]));
1413 }
1414 blocks.extend(c.blocks);
1415 DocumentTableCell {
1416 blocks,
1417 column_span: c.column_span,
1418 row_span: c.row_span,
1419 }
1420 })
1421 .collect();
1422 rows.push(DocumentTableRow {
1423 cells,
1424 is_header: false,
1425 });
1426 }
1427 }
1428 W_DRAWING => {
1429 if let Some(ParseState::Drawing {
1431 pending_rid,
1432 pending_alt,
1433 }) = stack.pop()
1434 {
1435 let (data, extension) = if let (Some(rid), Some(arch), Some(rels)) = (
1436 pending_rid.as_ref(),
1437 ctx.archive.as_deref_mut(),
1438 ctx.relationships,
1439 ) {
1440 if let Some(part_path) = rels.resolve(rid) {
1441 match read_zip_part(arch, part_path) {
1442 Ok(bytes) => (Some(bytes), extension_from_filename(part_path)),
1443 Err(_) => (None, None),
1444 }
1445 } else {
1446 (None, None)
1447 }
1448 } else {
1449 (None, None)
1450 };
1451
1452 let alt_text = pending_alt.or_else(|| Some("[image]".to_owned()));
1453
1454 sink.on_event(&DocumentEvent::Image(DocumentImage {
1455 alt_text,
1456 data,
1457 extension,
1458 }))?;
1459 }
1460 }
1461 _ => {}
1462 }
1463
1464 Ok(())
1465}
1466
1467fn inside_table(stack: &[ParseState]) -> bool {
1474 stack.iter().any(|s| matches!(s, ParseState::Table { .. }))
1475}
1476
1477fn extract_val(tag: &quick_xml::events::BytesStart) -> Option<String> {
1479 for attr in tag.attributes().flatten() {
1480 if attr.key.as_ref() == W_VAL {
1481 return attr
1482 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1483 .ok()
1484 .map(std::borrow::Cow::into_owned);
1485 }
1486 }
1487 None
1488}
1489
1490fn extract_bool_attr(tag: &quick_xml::events::BytesStart) -> Option<bool> {
1493 match extract_val(tag) {
1494 Some(v) => {
1495 let lower = v.to_lowercase();
1496 if lower == "false" || lower == "0" {
1497 Some(false)
1498 } else {
1499 Some(true)
1500 }
1501 }
1502 None => Some(true),
1503 }
1504}
1505
1506fn has_preserve_space(tag: &quick_xml::events::BytesStart) -> bool {
1508 for attr in tag.attributes().flatten() {
1509 if attr.key.as_ref() == b"xml:space" {
1510 return attr
1511 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1512 .ok()
1513 .is_some_and(|v| v.as_ref() == "preserve");
1514 }
1515 }
1516 false
1517}
1518
1519fn parse_heading_level(style: &str) -> Option<u8> {
1521 let trimmed = style.trim();
1522 let lower = trimmed.to_lowercase();
1524 let digits = lower
1525 .strip_prefix("heading")
1526 .or_else(|| lower.strip_prefix("heading "));
1527 digits
1528 .and_then(|d| d.trim().parse::<u8>().ok())
1529 .filter(|&l| (1..=6).contains(&l))
1530}
1531
1532fn br_is_page_break(tag: &quick_xml::events::BytesStart) -> bool {
1534 for attr in tag.attributes().flatten() {
1535 if attr.key.as_ref() == W_TYPE {
1536 return attr
1537 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1538 .ok()
1539 .is_some_and(|v| v.as_ref() == "page");
1540 }
1541 }
1542 false
1543}
1544
1545fn find_word_document_xml(archive: &mut zip::ZipArchive<File>) -> Result<String> {
1547 for i in 0..archive.len() {
1548 let entry = archive
1549 .by_index(i)
1550 .map_err(|e| DocError::Zip(e.to_string()))?;
1551 if entry.name().to_lowercase() == "word/document.xml" {
1552 return Ok(entry.name().to_owned());
1553 }
1554 }
1555 Err(DocError::Format(
1556 "word/document.xml not found in DOCX archive".to_owned(),
1557 ))
1558}
1559
1560#[cfg(test)]
1565mod tests {
1566 use super::*;
1567 use easydoc_core::ContentCollector;
1568
1569 fn make_docx_xml(xml: &[u8]) -> Vec<u8> {
1572 use std::io::Write;
1573 let mut buf = Vec::new();
1574 {
1575 let w = std::io::Cursor::new(&mut buf);
1576 let mut zip = zip::ZipWriter::new(w);
1577 let options = zip::write::SimpleFileOptions::default()
1578 .compression_method(zip::CompressionMethod::Stored);
1579 zip.start_file("word/document.xml", options).unwrap();
1580 zip.write_all(xml).unwrap();
1581 zip.finish().unwrap();
1582 }
1583 buf
1584 }
1585
1586 fn write_temp_docx(xml: &[u8]) -> tempfile::NamedTempFile {
1588 let data = make_docx_xml(xml);
1589 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1590 std::io::Write::write_all(&mut tmp, &data).unwrap();
1591 tmp
1592 }
1593
1594 #[test]
1595 fn empty_document_emits_start_end() {
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:document>"#;
1600 let tmp = write_temp_docx(xml);
1601 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1602 let mut collector = ContentCollector::new();
1603 reader.read_events(&mut collector).unwrap();
1604 let content = collector.into_content();
1605 assert!(content.blocks.is_empty());
1606 }
1607
1608 #[test]
1609 fn simple_paragraph() {
1610 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1611<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1612 <w:body>
1613 <w:p>
1614 <w:r><w:t>Hello World</w:t></w:r>
1615 </w:p>
1616 </w:body>
1617</w:document>"#;
1618 let tmp = write_temp_docx(xml);
1619 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1620 let mut collector = ContentCollector::new();
1621 reader.read_events(&mut collector).unwrap();
1622 let content = collector.into_content();
1623 assert_eq!(content.blocks.len(), 1);
1624 match &content.blocks[0] {
1625 easydoc_core::DocumentBlock::Paragraph(runs) => {
1626 assert_eq!(runs.len(), 1);
1627 assert_eq!(runs[0].text, "Hello World");
1628 }
1629 _ => panic!("expected Paragraph"),
1630 }
1631 }
1632
1633 #[test]
1634 fn heading_detection() {
1635 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1636<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1637 <w:body>
1638 <w:p>
1639 <w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
1640 <w:r><w:t>Title</w:t></w:r>
1641 </w:p>
1642 </w:body>
1643</w:document>"#;
1644 let tmp = write_temp_docx(xml);
1645 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1646 let mut collector = ContentCollector::new();
1647 reader.read_events(&mut collector).unwrap();
1648 let content = collector.into_content();
1649 assert_eq!(content.blocks.len(), 1);
1650 match &content.blocks[0] {
1651 easydoc_core::DocumentBlock::Heading { level, runs } => {
1652 assert_eq!(*level, 1);
1653 assert_eq!(runs[0].text, "Title");
1654 }
1655 _ => panic!("expected Heading"),
1656 }
1657 }
1658
1659 #[test]
1660 fn bold_run() {
1661 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1662<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1663 <w:body>
1664 <w:p>
1665 <w:r><w:rPr><w:b/></w:rPr><w:t>Bold</w:t></w:r>
1666 </w:p>
1667 </w:body>
1668</w:document>"#;
1669 let tmp = write_temp_docx(xml);
1670 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1671 let mut collector = ContentCollector::new();
1672 reader.read_events(&mut collector).unwrap();
1673 let content = collector.into_content();
1674 match &content.blocks[0] {
1675 easydoc_core::DocumentBlock::Paragraph(runs) => {
1676 assert!(runs[0].bold);
1677 assert!(!runs[0].italic);
1678 }
1679 _ => panic!("expected Paragraph"),
1680 }
1681 }
1682
1683 #[test]
1684 fn page_break() {
1685 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1686<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1687 <w:body>
1688 <w:p>
1689 <w:r><w:br w:type="page"/></w:r>
1690 <w:r><w:t>After break</w:t></w:r>
1691 </w:p>
1692 </w:body>
1693</w:document>"#;
1694 let tmp = write_temp_docx(xml);
1695 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1696 let mut collector = ContentCollector::new();
1697 reader.read_events(&mut collector).unwrap();
1698 let content = collector.into_content();
1699 assert_eq!(content.blocks.len(), 2);
1701 assert!(matches!(
1702 content.blocks[0],
1703 easydoc_core::DocumentBlock::PageBreak
1704 ));
1705 }
1706
1707 #[test]
1708 fn simple_table() {
1709 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1710<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1711 <w:body>
1712 <w:tbl>
1713 <w:tr>
1714 <w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>
1715 <w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>
1716 </w:tr>
1717 <w:tr>
1718 <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
1719 <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
1720 </w:tr>
1721 </w:tbl>
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(), 1);
1730 match &content.blocks[0] {
1731 easydoc_core::DocumentBlock::Table(table) => {
1732 assert_eq!(table.rows.len(), 2);
1733 assert_eq!(table.rows[0].cells.len(), 2);
1734 let cell_text: String = table.rows[0].cells[0]
1736 .blocks
1737 .iter()
1738 .filter_map(|b| match b {
1739 easydoc_core::DocumentBlock::Paragraph(runs) => {
1740 Some(runs.iter().map(|r| r.text.as_str()).collect::<String>())
1741 }
1742 _ => None,
1743 })
1744 .collect();
1745 assert_eq!(cell_text, "A1");
1746 }
1747 _ => panic!("expected Table"),
1748 }
1749 }
1750
1751 #[test]
1752 fn mixed_content_paragraph_and_table() {
1753 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1754<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1755 <w:body>
1756 <w:p><w:r><w:t>Before</w:t></w:r></w:p>
1757 <w:tbl>
1758 <w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr>
1759 </w:tbl>
1760 <w:p><w:r><w:t>After</w:t></w:r></w:p>
1761 </w:body>
1762</w:document>"#;
1763 let tmp = write_temp_docx(xml);
1764 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1765 let mut collector = ContentCollector::new();
1766 reader.read_events(&mut collector).unwrap();
1767 let content = collector.into_content();
1768 assert_eq!(content.blocks.len(), 3);
1769 assert!(matches!(
1770 content.blocks[0],
1771 easydoc_core::DocumentBlock::Paragraph(_)
1772 ));
1773 assert!(matches!(
1774 content.blocks[1],
1775 easydoc_core::DocumentBlock::Table(_)
1776 ));
1777 assert!(matches!(
1778 content.blocks[2],
1779 easydoc_core::DocumentBlock::Paragraph(_)
1780 ));
1781 }
1782
1783 #[test]
1784 fn from_reader_basic() {
1785 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1786<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1787 <w:body>
1788 <w:p><w:r><w:t>Direct</w:t></w:r></w:p>
1789 </w:body>
1790</w:document>"#;
1791 let mut reader = DocxSaxReader::from_reader(&xml[..]);
1792 let mut collector = ContentCollector::new();
1793 reader.read_events(&mut collector).unwrap();
1794 let content = collector.into_content();
1795 assert_eq!(content.blocks.len(), 1);
1796 }
1797
1798 #[test]
1799 fn parse_heading_level_variants() {
1800 assert_eq!(parse_heading_level("Heading1"), Some(1));
1801 assert_eq!(parse_heading_level("Heading2"), Some(2));
1802 assert_eq!(parse_heading_level("heading3"), Some(3));
1803 assert_eq!(parse_heading_level("heading 4"), Some(4));
1804 assert_eq!(parse_heading_level("Heading7"), None);
1805 assert_eq!(parse_heading_level("Normal"), None);
1806 assert_eq!(parse_heading_level("Title"), None);
1807 }
1808
1809 #[test]
1810 fn drawing_emits_placeholder_image() {
1811 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1812<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1813 <w:body>
1814 <w:p>
1815 <w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"/></w:drawing></w:r>
1816 </w:p>
1817 </w:body>
1818</w:document>"#;
1819 let tmp = write_temp_docx(xml);
1820 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1821 let mut collector = ContentCollector::new();
1822 reader.read_events(&mut collector).unwrap();
1823 let content = collector.into_content();
1824 let has_image = content
1825 .blocks
1826 .iter()
1827 .any(|b| matches!(b, easydoc_core::DocumentBlock::Image(_)));
1828 assert!(has_image, "expected an Image block from drawing");
1829 }
1830
1831 #[test]
1832 fn italic_and_strikethrough() {
1833 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1834<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1835 <w:body>
1836 <w:p>
1837 <w:r><w:rPr><w:i/><w:strike/></w:rPr><w:t>Fancy</w:t></w:r>
1838 </w:p>
1839 </w:body>
1840</w:document>"#;
1841 let tmp = write_temp_docx(xml);
1842 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1843 let mut collector = ContentCollector::new();
1844 reader.read_events(&mut collector).unwrap();
1845 let content = collector.into_content();
1846 match &content.blocks[0] {
1847 easydoc_core::DocumentBlock::Paragraph(runs) => {
1848 assert!(runs[0].italic);
1849 assert!(runs[0].strikethrough);
1850 assert!(!runs[0].bold);
1851 }
1852 _ => panic!("expected Paragraph"),
1853 }
1854 }
1855
1856 #[test]
1857 fn multiple_runs_in_paragraph() {
1858 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1859<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1860 <w:body>
1861 <w:p>
1862 <w:r><w:t>Hello </w:t></w:r>
1863 <w:r><w:rPr><w:b/></w:rPr><w:t>World</w:t></w:r>
1864 </w:p>
1865 </w:body>
1866</w:document>"#;
1867 let tmp = write_temp_docx(xml);
1868 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1869 let mut collector = ContentCollector::new();
1870 reader.read_events(&mut collector).unwrap();
1871 let content = collector.into_content();
1872 match &content.blocks[0] {
1873 easydoc_core::DocumentBlock::Paragraph(runs) => {
1874 assert_eq!(runs.len(), 2);
1875 assert_eq!(runs[0].text, "Hello ");
1876 assert!(!runs[0].bold);
1877 assert_eq!(runs[1].text, "World");
1878 assert!(runs[1].bold);
1879 }
1880 _ => panic!("expected Paragraph"),
1881 }
1882 }
1883
1884 const MINIMAL_PNG: &[u8] = &[
1886 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
1887 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
1888 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
1889 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
1890 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
1891 ];
1892
1893 fn make_docx_with_image(xml: &[u8], rels_xml: &[u8], image_bytes: &[u8]) -> Vec<u8> {
1896 use std::io::Write;
1897 let mut buf = Vec::new();
1898 {
1899 let w = std::io::Cursor::new(&mut buf);
1900 let mut zip = zip::ZipWriter::new(w);
1901 let options = zip::write::SimpleFileOptions::default()
1902 .compression_method(zip::CompressionMethod::Stored);
1903
1904 zip.start_file("word/document.xml", options).unwrap();
1905 zip.write_all(xml).unwrap();
1906
1907 zip.start_file("word/_rels/document.xml.rels", options)
1908 .unwrap();
1909 zip.write_all(rels_xml).unwrap();
1910
1911 zip.start_file("word/media/image1.png", options).unwrap();
1912 zip.write_all(image_bytes).unwrap();
1913
1914 zip.finish().unwrap();
1915 }
1916 buf
1917 }
1918
1919 fn write_temp_docx_with_image(
1921 xml: &[u8],
1922 rels_xml: &[u8],
1923 image_bytes: &[u8],
1924 ) -> tempfile::NamedTempFile {
1925 let data = make_docx_with_image(xml, rels_xml, image_bytes);
1926 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1927 std::io::Write::write_all(&mut tmp, &data).unwrap();
1928 tmp
1929 }
1930
1931 #[test]
1932 fn from_reader_drawing_emits_placeholder_no_data() {
1933 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1935<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1936 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
1937 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
1938 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
1939 <w:body>
1940 <w:p>
1941 <w:r>
1942 <w:drawing>
1943 <wp:inline>
1944 <wp:docPr id="1" name="Picture 1" descr="My photo"/>
1945 <a:graphic>
1946 <a:graphicData>
1947 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
1948 <pic:blipFill>
1949 <a:blip r:embed="rId5"/>
1950 </pic:blipFill>
1951 </pic:pic>
1952 </a:graphicData>
1953 </a:graphic>
1954 </wp:inline>
1955 </w:drawing>
1956 </w:r>
1957 </w:p>
1958 </w:body>
1959</w:document>"#;
1960 let mut reader = DocxSaxReader::from_reader(&xml[..]);
1961 let mut collector = ContentCollector::new();
1962 reader.read_events(&mut collector).unwrap();
1963 let content = collector.into_content();
1964
1965 let img = content
1966 .blocks
1967 .iter()
1968 .find_map(|b| match b {
1969 easydoc_core::DocumentBlock::Image(img) => Some(img),
1970 _ => None,
1971 })
1972 .expect("expected an Image block");
1973
1974 assert!(img.data.is_none());
1976 assert_eq!(img.alt_text.as_deref(), Some("My photo"));
1978 }
1979
1980 #[test]
1981 fn from_path_drawing_extracts_real_image_data() {
1982 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1983<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1984 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
1985 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
1986 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
1987 <w:body>
1988 <w:p>
1989 <w:r>
1990 <w:drawing>
1991 <wp:inline>
1992 <wp:docPr id="1" name="Picture 1" descr="A tiny image"/>
1993 <a:graphic>
1994 <a:graphicData>
1995 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
1996 <pic:blipFill>
1997 <a:blip r:embed="rId5"/>
1998 </pic:blipFill>
1999 </pic:pic>
2000 </a:graphicData>
2001 </a:graphic>
2002 </wp:inline>
2003 </w:drawing>
2004 </w:r>
2005 </w:p>
2006 </w:body>
2007</w:document>"#;
2008
2009 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2010<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2011 <Relationship Id="rId1" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
2012 <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
2013</Relationships>"#;
2014
2015 let tmp = write_temp_docx_with_image(xml, rels_xml, MINIMAL_PNG);
2016 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2017 let mut collector = ContentCollector::new();
2018 reader.read_events(&mut collector).unwrap();
2019 let content = collector.into_content();
2020
2021 let img = content
2022 .blocks
2023 .iter()
2024 .find_map(|b| match b {
2025 easydoc_core::DocumentBlock::Image(img) => Some(img),
2026 _ => None,
2027 })
2028 .expect("expected an Image block");
2029
2030 assert_eq!(img.data.as_deref(), Some(MINIMAL_PNG));
2032 assert_eq!(img.extension.as_deref(), Some("png"));
2034 assert_eq!(img.alt_text.as_deref(), Some("A tiny image"));
2036 }
2037
2038 #[test]
2039 fn from_path_drawing_without_rels_emits_placeholder() {
2040 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2042<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2043 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2044 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2045 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2046 <w:body>
2047 <w:p>
2048 <w:r>
2049 <w:drawing>
2050 <wp:inline>
2051 <wp:docPr id="1" name="Pic"/>
2052 <a:graphic>
2053 <a:graphicData>
2054 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2055 <pic:blipFill>
2056 <a:blip r:embed="rId5"/>
2057 </pic:blipFill>
2058 </pic:pic>
2059 </a:graphicData>
2060 </a:graphic>
2061 </wp:inline>
2062 </w:drawing>
2063 </w:r>
2064 </w:p>
2065 </w:body>
2066</w:document>"#;
2067 let tmp = write_temp_docx(xml);
2068 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2069 let mut collector = ContentCollector::new();
2070 reader.read_events(&mut collector).unwrap();
2071 let content = collector.into_content();
2072
2073 let img = content
2074 .blocks
2075 .iter()
2076 .find_map(|b| match b {
2077 easydoc_core::DocumentBlock::Image(img) => Some(img),
2078 _ => None,
2079 })
2080 .expect("expected an Image block");
2081
2082 assert!(img.data.is_none());
2084 assert_eq!(img.alt_text.as_deref(), Some("[image]"));
2087 }
2088
2089 #[test]
2090 fn from_path_drawing_alt_from_name_when_no_descr() {
2091 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2092<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2093 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2094 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2095 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2096 <w:body>
2097 <w:p>
2098 <w:r>
2099 <w:drawing>
2100 <wp:inline>
2101 <wp:docPr id="1" name="Diagram"/>
2102 <a:graphic>
2103 <a:graphicData>
2104 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2105 <pic:blipFill>
2106 <a:blip r:embed="rId5"/>
2107 </pic:blipFill>
2108 </pic:pic>
2109 </a:graphicData>
2110 </a:graphic>
2111 </wp:inline>
2112 </w:drawing>
2113 </w:r>
2114 </w:p>
2115 </w:body>
2116</w:document>"#;
2117 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2118<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2119 <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
2120</Relationships>"#;
2121
2122 let tmp = write_temp_docx_with_image(xml, rels_xml, MINIMAL_PNG);
2123 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2124 let mut collector = ContentCollector::new();
2125 reader.read_events(&mut collector).unwrap();
2126 let content = collector.into_content();
2127
2128 let img = content
2129 .blocks
2130 .iter()
2131 .find_map(|b| match b {
2132 easydoc_core::DocumentBlock::Image(img) => Some(img),
2133 _ => None,
2134 })
2135 .expect("expected an Image block");
2136
2137 assert_eq!(img.alt_text.as_deref(), Some("[image]"));
2141 }
2142
2143 #[test]
2144 fn from_path_drawing_jpeg_extension() {
2145 use std::io::Write;
2146 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2147<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2148 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2149 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2150 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2151 <w:body>
2152 <w:p>
2153 <w:r>
2154 <w:drawing>
2155 <wp:inline>
2156 <wp:docPr id="1" name="Pic"/>
2157 <a:graphic>
2158 <a:graphicData>
2159 <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2160 <pic:blipFill>
2161 <a:blip r:embed="rId5"/>
2162 </pic:blipFill>
2163 </pic:pic>
2164 </a:graphicData>
2165 </a:graphic>
2166 </wp:inline>
2167 </w:drawing>
2168 </w:r>
2169 </w:p>
2170 </w:body>
2171</w:document>"#;
2172
2173 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2174<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2175 <Relationship Id="rId5" Target="media/photo.jpeg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
2176</Relationships>"#;
2177
2178 let zip_bytes = {
2180 let mut buf = Vec::new();
2181 {
2182 let w = std::io::Cursor::new(&mut buf);
2183 let mut zip = zip::ZipWriter::new(w);
2184 let options = zip::write::SimpleFileOptions::default()
2185 .compression_method(zip::CompressionMethod::Stored);
2186
2187 zip.start_file("word/document.xml", options).unwrap();
2188 zip.write_all(xml).unwrap();
2189
2190 zip.start_file("word/_rels/document.xml.rels", options)
2191 .unwrap();
2192 zip.write_all(rels_xml).unwrap();
2193
2194 zip.start_file("word/media/photo.jpeg", options).unwrap();
2196 zip.write_all(MINIMAL_PNG).unwrap();
2197
2198 zip.finish().unwrap();
2199 }
2200 buf
2201 };
2202
2203 let mut tmp = tempfile::NamedTempFile::new().unwrap();
2204 std::io::Write::write_all(&mut tmp, &zip_bytes).unwrap();
2205 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2206 let mut collector = ContentCollector::new();
2207 reader.read_events(&mut collector).unwrap();
2208 let content = collector.into_content();
2209
2210 let img = content
2211 .blocks
2212 .iter()
2213 .find_map(|b| match b {
2214 easydoc_core::DocumentBlock::Image(img) => Some(img),
2215 _ => None,
2216 })
2217 .expect("expected an Image block");
2218
2219 assert_eq!(img.data.as_deref(), Some(MINIMAL_PNG));
2220 assert_eq!(img.extension.as_deref(), Some("jpeg"));
2221 }
2222
2223 #[test]
2228 fn cell_without_merge_has_default_span() {
2229 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2230<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2231 <w:body>
2232 <w:tbl>
2233 <w:tr>
2234 <w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc>
2235 <w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc>
2236 </w:tr>
2237 </w:tbl>
2238 </w:body>
2239</w:document>"#;
2240 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2241 let mut collector = ContentCollector::new();
2242 reader.read_events(&mut collector).unwrap();
2243 let content = collector.into_content();
2244
2245 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2246 panic!("expected Table")
2247 };
2248 assert_eq!(table.rows[0].cells.len(), 2);
2249 assert_eq!(table.rows[0].cells[0].column_span, 1);
2250 assert_eq!(table.rows[0].cells[0].row_span, 1);
2251 assert_eq!(table.rows[0].cells[1].column_span, 1);
2252 assert_eq!(table.rows[0].cells[1].row_span, 1);
2253 }
2254
2255 #[test]
2256 fn gridspan_horizontal_merge_two_columns() {
2257 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2258<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2259 <w:body>
2260 <w:tbl>
2261 <w:tr>
2262 <w:tc>
2263 <w:tcPr><w:gridSpan w:val="2"/></w:tcPr>
2264 <w:p><w:r><w:t>Merged</w:t></w:r></w:p>
2265 </w:tc>
2266 </w:tr>
2267 <w:tr>
2268 <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
2269 <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
2270 </w:tr>
2271 </w:tbl>
2272 </w:body>
2273</w:document>"#;
2274 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2275 let mut collector = ContentCollector::new();
2276 reader.read_events(&mut collector).unwrap();
2277 let content = collector.into_content();
2278
2279 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2280 panic!("expected Table")
2281 };
2282 assert_eq!(table.rows[0].cells.len(), 1);
2284 assert_eq!(table.rows[0].cells[0].column_span, 2);
2285 assert_eq!(table.rows[0].cells[0].row_span, 1);
2286 assert_eq!(table.rows[1].cells.len(), 2);
2288 assert_eq!(table.rows[1].cells[0].column_span, 1);
2289 assert_eq!(table.rows[1].cells[1].column_span, 1);
2290 }
2291
2292 #[test]
2293 fn gridspan_horizontal_merge_three_columns() {
2294 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2295<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2296 <w:body>
2297 <w:tbl>
2298 <w:tr>
2299 <w:tc>
2300 <w:tcPr><w:gridSpan w:val="3"/></w:tcPr>
2301 <w:p><w:r><w:t>Wide</w:t></w:r></w:p>
2302 </w:tc>
2303 </w:tr>
2304 </w:tbl>
2305 </w:body>
2306</w:document>"#;
2307 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2308 let mut collector = ContentCollector::new();
2309 reader.read_events(&mut collector).unwrap();
2310 let content = collector.into_content();
2311
2312 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2313 panic!("expected Table")
2314 };
2315 assert_eq!(table.rows[0].cells.len(), 1);
2316 assert_eq!(table.rows[0].cells[0].column_span, 3);
2317 }
2318
2319 #[test]
2320 fn vmerge_restart_sets_row_span_one() {
2321 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2322<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2323 <w:body>
2324 <w:tbl>
2325 <w:tr>
2326 <w:tc>
2327 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2328 <w:p><w:r><w:t>Start</w:t></w:r></w:p>
2329 </w:tc>
2330 <w:tc><w:p><w:r><w:t>Right</w:t></w:r></w:p></w:tc>
2331 </w:tr>
2332 </w:tbl>
2333 </w:body>
2334</w:document>"#;
2335 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2336 let mut collector = ContentCollector::new();
2337 reader.read_events(&mut collector).unwrap();
2338 let content = collector.into_content();
2339
2340 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2341 panic!("expected Table")
2342 };
2343 assert_eq!(table.rows[0].cells[0].row_span, 1);
2345 assert_eq!(table.rows[0].cells[0].column_span, 1);
2346 }
2347
2348 #[test]
2349 fn vmerge_continue_sets_row_span_zero() {
2350 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2351<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2352 <w:body>
2353 <w:tbl>
2354 <w:tr>
2355 <w:tc>
2356 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2357 <w:p><w:r><w:t>Start</w:t></w:r></w:p>
2358 </w:tc>
2359 <w:tc><w:p><w:r><w:t>R1</w:t></w:r></w:p></w:tc>
2360 </w:tr>
2361 <w:tr>
2362 <w:tc>
2363 <w:tcPr><w:vMerge w:val="continue"/></w:tcPr>
2364 <w:p/>
2365 </w:tc>
2366 <w:tc><w:p><w:r><w:t>R2</w:t></w:r></w:p></w:tc>
2367 </w:tr>
2368 </w:tbl>
2369 </w:body>
2370</w:document>"#;
2371 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2372 let mut collector = ContentCollector::new();
2373 reader.read_events(&mut collector).unwrap();
2374 let content = collector.into_content();
2375
2376 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2377 panic!("expected Table")
2378 };
2379 assert_eq!(table.rows[0].cells[0].row_span, 2);
2381 assert_eq!(table.rows[1].cells[0].row_span, 0);
2383 }
2384
2385 #[test]
2386 fn vmerge_no_val_treated_as_continue() {
2387 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2389<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2390 <w:body>
2391 <w:tbl>
2392 <w:tr>
2393 <w:tc>
2394 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2395 <w:p><w:r><w:t>Top</w:t></w:r></w:p>
2396 </w:tc>
2397 </w:tr>
2398 <w:tr>
2399 <w:tc>
2400 <w:tcPr><w:vMerge/></w:tcPr>
2401 <w:p/>
2402 </w:tc>
2403 </w:tr>
2404 <w:tr>
2405 <w:tc>
2406 <w:tcPr><w:vMerge/></w:tcPr>
2407 <w:p/>
2408 </w:tc>
2409 </w:tr>
2410 </w:tbl>
2411 </w:body>
2412</w:document>"#;
2413 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2414 let mut collector = ContentCollector::new();
2415 reader.read_events(&mut collector).unwrap();
2416 let content = collector.into_content();
2417
2418 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2419 panic!("expected Table")
2420 };
2421 assert_eq!(table.rows[0].cells[0].row_span, 3);
2423 assert_eq!(table.rows[1].cells[0].row_span, 0);
2425 assert_eq!(table.rows[2].cells[0].row_span, 0);
2427 }
2428
2429 #[test]
2430 fn mixed_gridspan_and_vmerge() {
2431 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2432<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2433 <w:body>
2434 <w:tbl>
2435 <w:tr>
2436 <w:tc>
2437 <w:tcPr>
2438 <w:gridSpan w:val="2"/>
2439 <w:vMerge w:val="restart"/>
2440 </w:tcPr>
2441 <w:p><w:r><w:t>Big</w:t></w:r></w:p>
2442 </w:tc>
2443 <w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc>
2444 </w:tr>
2445 <w:tr>
2446 <w:tc>
2447 <w:tcPr>
2448 <w:gridSpan w:val="2"/>
2449 <w:vMerge w:val="continue"/>
2450 </w:tcPr>
2451 <w:p/>
2452 </w:tc>
2453 <w:tc><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc>
2454 </w:tr>
2455 </w:tbl>
2456 </w:body>
2457</w:document>"#;
2458 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2459 let mut collector = ContentCollector::new();
2460 reader.read_events(&mut collector).unwrap();
2461 let content = collector.into_content();
2462
2463 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2464 panic!("expected Table")
2465 };
2466 assert_eq!(table.rows[0].cells[0].column_span, 2);
2468 assert_eq!(table.rows[0].cells[0].row_span, 2);
2469 assert_eq!(table.rows[1].cells[0].column_span, 2);
2471 assert_eq!(table.rows[1].cells[0].row_span, 0);
2472 }
2473
2474 #[test]
2475 fn vmerge_three_row_merge_sets_restart_span_three() {
2476 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2478<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2479 <w:body>
2480 <w:tbl>
2481 <w:tr>
2482 <w:tc>
2483 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2484 <w:p><w:r><w:t>Head</w:t></w:r></w:p>
2485 </w:tc>
2486 <w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc>
2487 </w:tr>
2488 <w:tr>
2489 <w:tc>
2490 <w:tcPr><w:vMerge w:val="continue"/></w:tcPr>
2491 <w:p/>
2492 </w:tc>
2493 <w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc>
2494 </w:tr>
2495 <w:tr>
2496 <w:tc>
2497 <w:tcPr><w:vMerge w:val="continue"/></w:tcPr>
2498 <w:p/>
2499 </w:tc>
2500 <w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc>
2501 </w:tr>
2502 </w:tbl>
2503 </w:body>
2504</w:document>"#;
2505 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2506 let mut collector = ContentCollector::new();
2507 reader.read_events(&mut collector).unwrap();
2508 let content = collector.into_content();
2509
2510 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2511 panic!("expected Table")
2512 };
2513 assert_eq!(table.rows[0].cells[0].row_span, 3, "restart 应跨 3 行");
2514 assert_eq!(table.rows[1].cells[0].row_span, 0, "continue 保持 0");
2515 assert_eq!(table.rows[2].cells[0].row_span, 0, "continue 保持 0");
2516 }
2517
2518 #[test]
2519 fn vmerge_single_row_restart_stays_one() {
2520 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2522<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2523 <w:body>
2524 <w:tbl>
2525 <w:tr>
2526 <w:tc>
2527 <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2528 <w:p><w:r><w:t>Solo</w:t></w:r></w:p>
2529 </w:tc>
2530 <w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>
2531 </w:tr>
2532 </w:tbl>
2533 </w:body>
2534</w:document>"#;
2535 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2536 let mut collector = ContentCollector::new();
2537 reader.read_events(&mut collector).unwrap();
2538 let content = collector.into_content();
2539
2540 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2541 panic!("expected Table")
2542 };
2543 assert_eq!(
2544 table.rows[0].cells[0].row_span, 1,
2545 "无 continue 时 restart 保持 1"
2546 );
2547 }
2548
2549 #[test]
2550 fn gridspan_val_one_is_noop() {
2551 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2553<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2554 <w:body>
2555 <w:tbl>
2556 <w:tr>
2557 <w:tc>
2558 <w:tcPr><w:gridSpan w:val="1"/></w:tcPr>
2559 <w:p><w:r><w:t>X</w:t></w:r></w:p>
2560 </w:tc>
2561 <w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
2562 </w:tr>
2563 </w:tbl>
2564 </w:body>
2565</w:document>"#;
2566 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2567 let mut collector = ContentCollector::new();
2568 reader.read_events(&mut collector).unwrap();
2569 let content = collector.into_content();
2570
2571 let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2572 panic!("expected Table")
2573 };
2574 assert_eq!(table.rows[0].cells[0].column_span, 1);
2575 assert_eq!(table.rows[0].cells[1].column_span, 1);
2576 }
2577
2578 #[test]
2583 fn inline_math_in_paragraph() {
2584 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2586<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2587 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2588 <w:body>
2589 <w:p>
2590 <w:r><w:t>Formula: </w:t></w:r>
2591 <m:oMath><m:r><m:t>x</m:t></m:r></m:oMath>
2592 </w:p>
2593 </w:body>
2594</w:document>"#;
2595 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2596 let blocks = reader.read_blocks().unwrap();
2597 assert_eq!(blocks.len(), 2);
2599 match &blocks[0] {
2600 DocumentBlock::Paragraph(runs) => {
2601 assert_eq!(runs.len(), 1);
2602 assert_eq!(runs[0].text, "Formula: ");
2603 }
2604 other => panic!("expected Paragraph, got {other:?}"),
2605 }
2606 match &blocks[1] {
2607 DocumentBlock::Math {
2608 omml,
2609 latex,
2610 display,
2611 } => {
2612 let xml_str = omml.as_ref().expect("omml should be Some");
2613 assert!(xml_str.contains("<m:oMath>"), "omml = {xml_str}");
2614 assert!(xml_str.contains("</m:oMath>"), "omml = {xml_str}");
2615 assert!(
2616 xml_str.contains("<m:r><m:t>x</m:t></m:r>"),
2617 "omml = {xml_str}"
2618 );
2619 assert!(latex.is_none());
2620 assert!(!display, "inline math should have display=false");
2621 }
2622 other => panic!("expected Math, got {other:?}"),
2623 }
2624 }
2625
2626 #[test]
2627 fn display_math_with_omathpara() {
2628 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2630<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2631 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2632 <w:body>
2633 <w:p><w:r><w:t>Before</w:t></w:r></w:p>
2634 <m:oMathPara><m:oMath><m:r><m:t>E=mc^2</m:t></m:r></m:oMath></m:oMathPara>
2635 <w:p><w:r><w:t>After</w:t></w:r></w:p>
2636 </w:body>
2637</w:document>"#;
2638 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2639 let blocks = reader.read_blocks().unwrap();
2640 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2642 match &blocks[0] {
2643 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Before"),
2644 other => panic!("expected Paragraph, got {other:?}"),
2645 }
2646 match &blocks[1] {
2647 DocumentBlock::Math { omml, display, .. } => {
2648 let xml_str = omml.as_ref().expect("omml should be Some");
2649 assert!(xml_str.contains("<m:oMathPara>"), "omml = {xml_str}");
2650 assert!(xml_str.contains("</m:oMathPara>"), "omml = {xml_str}");
2651 assert!(*display, "oMathPara should have display=true");
2652 }
2653 other => panic!("expected Math, got {other:?}"),
2654 }
2655 match &blocks[2] {
2656 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "After"),
2657 other => panic!("expected Paragraph, got {other:?}"),
2658 }
2659 }
2660
2661 #[test]
2662 fn mixed_text_math_text_in_paragraph() {
2663 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2665<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2666 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2667 <w:body>
2668 <w:p>
2669 <w:r><w:t>Let </w:t></w:r>
2670 <m:oMath><m:r><m:t>y</m:t></m:r></m:oMath>
2671 <w:r><w:t> be the result.</w:t></w:r>
2672 </w:p>
2673 </w:body>
2674</w:document>"#;
2675 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2676 let blocks = reader.read_blocks().unwrap();
2677 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2679 match &blocks[0] {
2680 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Let "),
2681 other => panic!("expected Paragraph, got {other:?}"),
2682 }
2683 assert!(matches!(
2684 &blocks[1],
2685 DocumentBlock::Math { display: false, .. }
2686 ));
2687 match &blocks[2] {
2688 DocumentBlock::Paragraph(runs) => {
2689 assert_eq!(runs[0].text, " be the result.");
2690 }
2691 other => panic!("expected Paragraph, got {other:?}"),
2692 }
2693 }
2694
2695 #[test]
2696 fn nested_math_structure() {
2697 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2699<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2700 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2701 <w:body>
2702 <m:oMath>
2703 <m:f>
2704 <m:num><m:r><m:t>a</m:t></m:r></m:num>
2705 <m:den><m:r><m:t>b</m:t></m:r></m:den>
2706 </m:f>
2707 </m:oMath>
2708 </w:body>
2709</w:document>"#;
2710 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2711 let blocks = reader.read_blocks().unwrap();
2712 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2713 match &blocks[0] {
2714 DocumentBlock::Math { omml, display, .. } => {
2715 let xml_str = omml.as_ref().expect("omml should be Some");
2716 assert!(xml_str.contains("<m:f>"), "omml = {xml_str}");
2718 assert!(xml_str.contains("</m:f>"), "omml = {xml_str}");
2719 assert!(xml_str.contains("<m:num>"), "omml = {xml_str}");
2720 assert!(xml_str.contains("<m:den>"), "omml = {xml_str}");
2721 assert!(xml_str.contains("<m:t>a</m:t>"), "omml = {xml_str}");
2722 assert!(xml_str.contains("<m:t>b</m:t>"), "omml = {xml_str}");
2723 assert!(!display, "standalone oMath should have display=false");
2724 }
2725 other => panic!("expected Math, got {other:?}"),
2726 }
2727 }
2728
2729 #[test]
2730 fn block_level_math_without_omathpara() {
2731 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2733<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2734 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2735 <w:body>
2736 <w:p><w:r><w:t>See equation:</w:t></w:r></w:p>
2737 <m:oMath><m:r><m:t>x+1=0</m:t></m:r></m:oMath>
2738 </w:body>
2739</w:document>"#;
2740 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2741 let blocks = reader.read_blocks().unwrap();
2742 assert_eq!(blocks.len(), 2, "blocks = {blocks:?}");
2743 assert!(matches!(&blocks[0], DocumentBlock::Paragraph(_)));
2744 match &blocks[1] {
2745 DocumentBlock::Math { omml, display, .. } => {
2746 let xml_str = omml.as_ref().expect("omml should be Some");
2747 assert!(xml_str.contains("<m:oMath>"));
2748 assert!(
2749 !display,
2750 "bare oMathPara-less math should have display=false"
2751 );
2752 }
2753 other => panic!("expected Math, got {other:?}"),
2754 }
2755 }
2756
2757 #[test]
2758 fn multiple_math_in_one_paragraph() {
2759 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2761<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2762 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2763 <w:body>
2764 <w:p>
2765 <m:oMath><m:r><m:t>a</m:t></m:r></m:oMath>
2766 <w:r><w:t> + </w:t></w:r>
2767 <m:oMath><m:r><m:t>b</m:t></m:r></m:oMath>
2768 </w:p>
2769 </w:body>
2770</w:document>"#;
2771 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2772 let blocks = reader.read_blocks().unwrap();
2773 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2775 assert!(matches!(
2776 &blocks[0],
2777 DocumentBlock::Math { display: false, .. }
2778 ));
2779 match &blocks[1] {
2780 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, " + "),
2781 other => panic!("expected Paragraph, got {other:?}"),
2782 }
2783 assert!(matches!(
2784 &blocks[2],
2785 DocumentBlock::Math { display: false, .. }
2786 ));
2787 }
2788
2789 #[test]
2794 fn single_list_item() {
2795 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2796<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2797 <w:body>
2798 <w:p>
2799 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2800 <w:r><w:t>Item one</w:t></w:r>
2801 </w:p>
2802 </w:body>
2803</w:document>"#;
2804 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2805 let blocks = reader.read_blocks().unwrap();
2806 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2807 match &blocks[0] {
2808 DocumentBlock::List(list) => {
2809 assert_eq!(list.items.len(), 1);
2810 match &list.items[0].blocks[0] {
2811 DocumentBlock::Paragraph(runs) => {
2812 assert_eq!(runs[0].text, "Item one");
2813 }
2814 other => panic!("expected Paragraph inside list item, got {other:?}"),
2815 }
2816 }
2817 other => panic!("expected List, got {other:?}"),
2818 }
2819 }
2820
2821 #[test]
2822 fn consecutive_list_items_merged() {
2823 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2824<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2825 <w:body>
2826 <w:p>
2827 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2828 <w:r><w:t>First</w:t></w:r>
2829 </w:p>
2830 <w:p>
2831 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2832 <w:r><w:t>Second</w:t></w:r>
2833 </w:p>
2834 <w:p>
2835 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2836 <w:r><w:t>Third</w:t></w:r>
2837 </w:p>
2838 </w:body>
2839</w:document>"#;
2840 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2841 let blocks = reader.read_blocks().unwrap();
2842 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2844 match &blocks[0] {
2845 DocumentBlock::List(list) => {
2846 assert_eq!(list.items.len(), 3);
2847 }
2848 other => panic!("expected List, got {other:?}"),
2849 }
2850 }
2851
2852 #[test]
2853 fn list_followed_by_paragraph_flushes() {
2854 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2855<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2856 <w:body>
2857 <w:p>
2858 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2859 <w:r><w:t>List item</w:t></w:r>
2860 </w:p>
2861 <w:p>
2862 <w:r><w:t>Normal paragraph</w:t></w:r>
2863 </w:p>
2864 </w:body>
2865</w:document>"#;
2866 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2867 let blocks = reader.read_blocks().unwrap();
2868 assert_eq!(blocks.len(), 2, "blocks = {blocks:?}");
2869 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2870 match &blocks[1] {
2871 DocumentBlock::Paragraph(runs) => {
2872 assert_eq!(runs[0].text, "Normal paragraph");
2873 }
2874 other => panic!("expected Paragraph, got {other:?}"),
2875 }
2876 }
2877
2878 #[test]
2879 fn two_separate_lists() {
2880 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2881<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2882 <w:body>
2883 <w:p>
2884 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2885 <w:r><w:t>A</w:t></w:r>
2886 </w:p>
2887 <w:p>
2888 <w:r><w:t>Separator</w:t></w:r>
2889 </w:p>
2890 <w:p>
2891 <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2892 <w:r><w:t>B</w:t></w:r>
2893 </w:p>
2894 </w:body>
2895</w:document>"#;
2896 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2897 let blocks = reader.read_blocks().unwrap();
2898 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2899 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2900 assert!(matches!(&blocks[1], DocumentBlock::Paragraph(_)));
2901 assert!(matches!(&blocks[2], DocumentBlock::List(_)));
2902 }
2903
2904 #[test]
2905 fn list_at_document_end_flushes() {
2906 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2907<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2908 <w:body>
2909 <w:p>
2910 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2911 <w:r><w:t>Last item</w:t></w:r>
2912 </w:p>
2913 </w:body>
2914</w:document>"#;
2915 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2916 let blocks = reader.read_blocks().unwrap();
2917 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2918 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2919 }
2920
2921 #[test]
2926 fn hyperlink_sets_run_field() {
2927 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2928<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2929 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2930 <w:body>
2931 <w:p>
2932 <w:hyperlink r:id="rId5">
2933 <w:r><w:t>Click here</w:t></w:r>
2934 </w:hyperlink>
2935 </w:p>
2936 </w:body>
2937</w:document>"#;
2938 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2939 let blocks = reader.read_blocks().unwrap();
2940 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2941 match &blocks[0] {
2942 DocumentBlock::Paragraph(runs) => {
2943 assert_eq!(runs.len(), 1);
2944 assert_eq!(runs[0].text, "Click here");
2945 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId5"));
2946 }
2947 other => panic!("expected Paragraph, got {other:?}"),
2948 }
2949 }
2950
2951 #[test]
2952 fn hyperlink_mixed_with_normal_runs() {
2953 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2954<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2955 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2956 <w:body>
2957 <w:p>
2958 <w:r><w:t>Normal </w:t></w:r>
2959 <w:hyperlink r:id="rId3">
2960 <w:r><w:t>link text</w:t></w:r>
2961 </w:hyperlink>
2962 <w:r><w:t> after</w:t></w:r>
2963 </w:p>
2964 </w:body>
2965</w:document>"#;
2966 let mut reader = DocxSaxReader::from_reader(&xml[..]);
2967 let blocks = reader.read_blocks().unwrap();
2968 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2969 match &blocks[0] {
2970 DocumentBlock::Paragraph(runs) => {
2971 assert_eq!(runs.len(), 3);
2972 assert_eq!(runs[0].text, "Normal ");
2973 assert!(runs[0].hyperlink.is_none());
2974 assert_eq!(runs[1].text, "link text");
2975 assert_eq!(runs[1].hyperlink.as_deref(), Some("rId3"));
2976 assert_eq!(runs[2].text, " after");
2977 assert!(runs[2].hyperlink.is_none());
2978 }
2979 other => panic!("expected Paragraph, got {other:?}"),
2980 }
2981 }
2982
2983 #[test]
2984 fn hyperlink_with_bold_run() {
2985 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2986<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2987 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2988 <w:body>
2989 <w:p>
2990 <w:hyperlink r:id="rId10">
2991 <w:r>
2992 <w:rPr><w:b/></w:rPr>
2993 <w:t>Bold link</w:t>
2994 </w:r>
2995 </w:hyperlink>
2996 </w:p>
2997 </w:body>
2998</w:document>"#;
2999 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3000 let blocks = reader.read_blocks().unwrap();
3001 match &blocks[0] {
3002 DocumentBlock::Paragraph(runs) => {
3003 assert_eq!(runs[0].text, "Bold link");
3004 assert!(runs[0].bold);
3005 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId10"));
3006 }
3007 other => panic!("expected Paragraph, got {other:?}"),
3008 }
3009 }
3010
3011 #[test]
3012 fn no_hyperlink_field_when_not_in_hyperlink() {
3013 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3014<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3015 <w:body>
3016 <w:p>
3017 <w:r><w:t>No link</w:t></w:r>
3018 </w:p>
3019 </w:body>
3020</w:document>"#;
3021 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3022 let blocks = reader.read_blocks().unwrap();
3023 match &blocks[0] {
3024 DocumentBlock::Paragraph(runs) => {
3025 assert!(runs[0].hyperlink.is_none());
3026 }
3027 other => panic!("expected Paragraph, got {other:?}"),
3028 }
3029 }
3030
3031 #[test]
3036 fn nested_table_in_cell() {
3037 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3038<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3039 <w:body>
3040 <w:tbl>
3041 <w:tr>
3042 <w:tc>
3043 <w:p><w:r><w:t>Outer</w:t></w:r></w:p>
3044 <w:tbl>
3045 <w:tr>
3046 <w:tc><w:p><w:r><w:t>Inner A</w:t></w:r></w:p></w:tc>
3047 <w:tc><w:p><w:r><w:t>Inner B</w:t></w:r></w:p></w:tc>
3048 </w:tr>
3049 </w:tbl>
3050 </w:tc>
3051 <w:tc><w:p><w:r><w:t>Right</w:t></w:r></w:p></w:tc>
3052 </w:tr>
3053 </w:tbl>
3054 </w:body>
3055</w:document>"#;
3056 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3057 let blocks = reader.read_blocks().unwrap();
3058 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3059 match &blocks[0] {
3060 DocumentBlock::Table(outer) => {
3061 assert_eq!(outer.rows.len(), 1);
3062 assert_eq!(outer.rows[0].cells.len(), 2);
3063 let cell0 = &outer.rows[0].cells[0];
3065 assert!(cell0.blocks.len() >= 2, "cell0.blocks = {:?}", cell0.blocks);
3066 assert!(matches!(&cell0.blocks[0], DocumentBlock::Paragraph(_)));
3067 assert!(matches!(&cell0.blocks[1], DocumentBlock::Table(_)));
3068 if let DocumentBlock::Table(inner) = &cell0.blocks[1] {
3069 assert_eq!(inner.rows.len(), 1);
3070 assert_eq!(inner.rows[0].cells.len(), 2);
3071 }
3072 let cell1 = &outer.rows[0].cells[1];
3074 assert_eq!(cell1.blocks.len(), 1);
3075 }
3076 other => panic!("expected Table, got {other:?}"),
3077 }
3078 }
3079
3080 #[test]
3081 fn nested_table_only_in_cell() {
3082 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3084<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3085 <w:body>
3086 <w:tbl>
3087 <w:tr>
3088 <w:tc>
3089 <w:tbl>
3090 <w:tr>
3091 <w:tc><w:p><w:r><w:t>Deep</w:t></w:r></w:p></w:tc>
3092 </w:tr>
3093 </w:tbl>
3094 </w:tc>
3095 </w:tr>
3096 </w:tbl>
3097 </w:body>
3098</w:document>"#;
3099 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3100 let blocks = reader.read_blocks().unwrap();
3101 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3102 match &blocks[0] {
3103 DocumentBlock::Table(outer) => {
3104 let cell0 = &outer.rows[0].cells[0];
3105 assert_eq!(cell0.blocks.len(), 1);
3106 assert!(matches!(&cell0.blocks[0], DocumentBlock::Table(_)));
3107 }
3108 other => panic!("expected Table, got {other:?}"),
3109 }
3110 }
3111
3112 #[test]
3113 fn flat_table_still_works() {
3114 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3116<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3117 <w:body>
3118 <w:tbl>
3119 <w:tr>
3120 <w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>
3121 <w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
3122 </w:tr>
3123 </w:tbl>
3124 </w:body>
3125</w:document>"#;
3126 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3127 let blocks = reader.read_blocks().unwrap();
3128 assert_eq!(blocks.len(), 1);
3129 match &blocks[0] {
3130 DocumentBlock::Table(table) => {
3131 assert_eq!(table.rows.len(), 1);
3132 assert_eq!(table.rows[0].cells.len(), 2);
3133 let cell_text = |cell: &easydoc_core::DocumentTableCell| -> String {
3135 cell.blocks
3136 .iter()
3137 .filter_map(|b| match b {
3138 DocumentBlock::Paragraph(runs) => {
3139 Some(runs.iter().map(|r| r.text.as_str()).collect::<String>())
3140 }
3141 _ => None,
3142 })
3143 .collect()
3144 };
3145 assert_eq!(cell_text(&table.rows[0].cells[0]), "X");
3146 assert_eq!(cell_text(&table.rows[0].cells[1]), "Y");
3147 }
3148 other => panic!("expected Table, got {other:?}"),
3149 }
3150 }
3151
3152 #[test]
3157 fn list_then_hyperlink_then_table() {
3158 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3160<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3161 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3162 <w:body>
3163 <w:p>
3164 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3165 <w:r><w:t>Item</w:t></w:r>
3166 </w:p>
3167 <w:p>
3168 <w:hyperlink r:id="rId7">
3169 <w:r><w:t>Link</w:t></w:r>
3170 </w:hyperlink>
3171 </w:p>
3172 <w:tbl>
3173 <w:tr>
3174 <w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc>
3175 </w:tr>
3176 </w:tbl>
3177 </w:body>
3178</w:document>"#;
3179 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3180 let blocks = reader.read_blocks().unwrap();
3181 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
3182 assert!(matches!(&blocks[0], DocumentBlock::List(_)));
3183 match &blocks[1] {
3184 DocumentBlock::Paragraph(runs) => {
3185 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId7"));
3186 }
3187 other => panic!("expected Paragraph, got {other:?}"),
3188 }
3189 assert!(matches!(&blocks[2], DocumentBlock::Table(_)));
3190 }
3191
3192 fn make_docx_with_numbering(doc_xml: &[u8], numbering_xml: &[u8]) -> Vec<u8> {
3198 use std::io::Write;
3199 let mut buf = Vec::new();
3200 {
3201 let w = std::io::Cursor::new(&mut buf);
3202 let mut zip = zip::ZipWriter::new(w);
3203 let options = zip::write::SimpleFileOptions::default()
3204 .compression_method(zip::CompressionMethod::Stored);
3205
3206 zip.start_file("word/document.xml", options).unwrap();
3207 zip.write_all(doc_xml).unwrap();
3208
3209 zip.start_file("word/numbering.xml", options).unwrap();
3210 zip.write_all(numbering_xml).unwrap();
3211
3212 zip.finish().unwrap();
3213 }
3214 buf
3215 }
3216
3217 fn make_docx_with_rels_and_numbering(
3219 doc_xml: &[u8],
3220 rels_xml: &[u8],
3221 numbering_xml: &[u8],
3222 ) -> Vec<u8> {
3223 use std::io::Write;
3224 let mut buf = Vec::new();
3225 {
3226 let w = std::io::Cursor::new(&mut buf);
3227 let mut zip = zip::ZipWriter::new(w);
3228 let options = zip::write::SimpleFileOptions::default()
3229 .compression_method(zip::CompressionMethod::Stored);
3230
3231 zip.start_file("word/document.xml", options).unwrap();
3232 zip.write_all(doc_xml).unwrap();
3233
3234 zip.start_file("word/_rels/document.xml.rels", options)
3235 .unwrap();
3236 zip.write_all(rels_xml).unwrap();
3237
3238 zip.start_file("word/numbering.xml", options).unwrap();
3239 zip.write_all(numbering_xml).unwrap();
3240
3241 zip.finish().unwrap();
3242 }
3243 buf
3244 }
3245
3246 #[test]
3247 fn e2e_ordered_list_from_numbering_xml() {
3248 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3249<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3250 <w:body>
3251 <w:p>
3252 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3253 <w:r><w:t>First</w:t></w:r>
3254 </w:p>
3255 <w:p>
3256 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3257 <w:r><w:t>Second</w:t></w:r>
3258 </w:p>
3259 <w:p>
3260 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3261 <w:r><w:t>Third</w:t></w:r>
3262 </w:p>
3263 </w:body>
3264</w:document>"#;
3265
3266 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3267<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3268 <w:abstractNum w:abstractNumId="0">
3269 <w:lvl w:ilvl="0">
3270 <w:start w:val="1"/>
3271 <w:numFmt w:val="decimal"/>
3272 <w:lvlText w:val="%1."/>
3273 </w:lvl>
3274 </w:abstractNum>
3275 <w:num w:numId="1">
3276 <w:abstractNumId w:val="0"/>
3277 </w:num>
3278</w:numbering>"#;
3279
3280 let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3281 let tmp = tempfile::NamedTempFile::new().unwrap();
3282 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3283 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3284 let blocks = reader.read_blocks().unwrap();
3285
3286 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3287 match &blocks[0] {
3288 DocumentBlock::List(list) => {
3289 assert!(list.ordered, "list should be ordered (decimal fmt)");
3290 assert_eq!(list.start_number, Some(1));
3291 assert_eq!(list.items.len(), 3);
3292 }
3293 other => panic!("expected List, got {other:?}"),
3294 }
3295 }
3296
3297 #[test]
3298 fn e2e_bullet_list_remains_unordered() {
3299 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3300<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3301 <w:body>
3302 <w:p>
3303 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3304 <w:r><w:t>Bullet A</w:t></w:r>
3305 </w:p>
3306 <w:p>
3307 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3308 <w:r><w:t>Bullet B</w:t></w:r>
3309 </w:p>
3310 </w:body>
3311</w:document>"#;
3312
3313 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3314<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3315 <w:abstractNum w:abstractNumId="0">
3316 <w:lvl w:ilvl="0">
3317 <w:numFmt w:val="bullet"/>
3318 <w:lvlText w:val="•"/>
3319 </w:lvl>
3320 </w:abstractNum>
3321 <w:num w:numId="1">
3322 <w:abstractNumId w:val="0"/>
3323 </w:num>
3324</w:numbering>"#;
3325
3326 let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3327 let tmp = tempfile::NamedTempFile::new().unwrap();
3328 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3329 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3330 let blocks = reader.read_blocks().unwrap();
3331
3332 match &blocks[0] {
3333 DocumentBlock::List(list) => {
3334 assert!(!list.ordered, "bullet list should be unordered");
3335 assert_eq!(list.start_number, None);
3336 assert_eq!(list.items.len(), 2);
3337 }
3338 other => panic!("expected List, got {other:?}"),
3339 }
3340 }
3341
3342 #[test]
3343 fn e2e_numbering_missing_numid_falls_back_to_unordered() {
3344 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3346<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3347 <w:body>
3348 <w:p>
3349 <w:pPr><w:numPr><w:numId w:val="99"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3350 <w:r><w:t>Unknown numId</w:t></w:r>
3351 </w:p>
3352 </w:body>
3353</w:document>"#;
3354
3355 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3356<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3357 <w:abstractNum w:abstractNumId="0">
3358 <w:lvl w:ilvl="0">
3359 <w:start w:val="1"/>
3360 <w:numFmt w:val="decimal"/>
3361 </w:lvl>
3362 </w:abstractNum>
3363 <w:num w:numId="1">
3364 <w:abstractNumId w:val="0"/>
3365 </w:num>
3366</w:numbering>"#;
3367
3368 let zip_data = make_docx_with_numbering(doc_xml, numbering_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::List(list) => {
3376 assert!(!list.ordered, "unknown numId should fallback to unordered");
3377 assert_eq!(list.start_number, None);
3378 }
3379 other => panic!("expected List, got {other:?}"),
3380 }
3381 }
3382
3383 #[test]
3384 fn e2e_no_numbering_xml_falls_back_to_unordered() {
3385 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3387<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3388 <w:body>
3389 <w:p>
3390 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3391 <w:r><w:t>Item</w:t></w:r>
3392 </w:p>
3393 </w:body>
3394</w:document>"#;
3395
3396 let zip_data = make_docx_xml(doc_xml);
3398 let tmp = tempfile::NamedTempFile::new().unwrap();
3399 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3400 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3401 let blocks = reader.read_blocks().unwrap();
3402
3403 match &blocks[0] {
3404 DocumentBlock::List(list) => {
3405 assert!(!list.ordered, "no numbering.xml => unordered fallback");
3406 assert_eq!(list.start_number, None);
3407 }
3408 other => panic!("expected List, got {other:?}"),
3409 }
3410 }
3411
3412 #[test]
3413 fn e2e_ordered_list_with_start_value() {
3414 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3415<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3416 <w:body>
3417 <w:p>
3418 <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3419 <w:r><w:t>Item five</w:t></w:r>
3420 </w:p>
3421 <w:p>
3422 <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3423 <w:r><w:t>Item six</w:t></w:r>
3424 </w:p>
3425 </w:body>
3426</w:document>"#;
3427
3428 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3429<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3430 <w:abstractNum w:abstractNumId="0">
3431 <w:lvl w:ilvl="0">
3432 <w:start w:val="5"/>
3433 <w:numFmt w:val="decimal"/>
3434 <w:lvlText w:val="%1."/>
3435 </w:lvl>
3436 </w:abstractNum>
3437 <w:num w:numId="2">
3438 <w:abstractNumId w:val="0"/>
3439 </w:num>
3440</w:numbering>"#;
3441
3442 let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3443 let tmp = tempfile::NamedTempFile::new().unwrap();
3444 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3445 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3446 let blocks = reader.read_blocks().unwrap();
3447
3448 match &blocks[0] {
3449 DocumentBlock::List(list) => {
3450 assert!(list.ordered);
3451 assert_eq!(list.start_number, Some(5));
3452 }
3453 other => panic!("expected List, got {other:?}"),
3454 }
3455 }
3456
3457 #[test]
3462 fn e2e_hyperlink_resolves_to_url() {
3463 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3464<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3465 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3466 <w:body>
3467 <w:p>
3468 <w:hyperlink r:id="rId10">
3469 <w:r><w:t>Visit example</w:t></w:r>
3470 </w:hyperlink>
3471 </w:p>
3472 </w:body>
3473</w:document>"#;
3474
3475 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3476<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3477 <Relationship Id="rId10" Target="https://example.com"
3478 Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
3479 TargetMode="External"/>
3480</Relationships>"#;
3481
3482 let zip_data = make_docx_with_image(doc_xml, rels_xml, &[]);
3483 let tmp = tempfile::NamedTempFile::new().unwrap();
3484 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3485 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3486 let blocks = reader.read_blocks().unwrap();
3487
3488 match &blocks[0] {
3489 DocumentBlock::Paragraph(runs) => {
3490 assert_eq!(runs.len(), 1);
3491 assert_eq!(runs[0].text, "Visit example");
3492 assert_eq!(
3493 runs[0].hyperlink.as_deref(),
3494 Some("https://example.com"),
3495 "hyperlink should be resolved to URL, not raw rId"
3496 );
3497 }
3498 other => panic!("expected Paragraph, got {other:?}"),
3499 }
3500 }
3501
3502 #[test]
3503 fn e2e_hyperlink_fallback_to_rid_when_no_rels() {
3504 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3506<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3507 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3508 <w:body>
3509 <w:p>
3510 <w:hyperlink r:id="rId5">
3511 <w:r><w:t>No rels</w:t></w:r>
3512 </w:hyperlink>
3513 </w:p>
3514 </w:body>
3515</w:document>"#;
3516
3517 let zip_data = make_docx_xml(doc_xml);
3518 let tmp = tempfile::NamedTempFile::new().unwrap();
3519 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3520 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3521 let blocks = reader.read_blocks().unwrap();
3522
3523 match &blocks[0] {
3524 DocumentBlock::Paragraph(runs) => {
3525 assert_eq!(runs[0].hyperlink.as_deref(), Some("rId5"));
3526 }
3527 other => panic!("expected Paragraph, got {other:?}"),
3528 }
3529 }
3530
3531 #[test]
3532 fn e2e_hyperlink_in_list_items_resolves() {
3533 let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3536<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3537 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3538 <w:body>
3539 <w:p>
3540 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3541 <w:r><w:t>See </w:t></w:r>
3542 <w:hyperlink r:id="rId20">
3543 <w:r><w:t>Rust lang</w:t></w:r>
3544 </w:hyperlink>
3545 </w:p>
3546 <w:p>
3547 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3548 <w:r><w:t>Plain item</w:t></w:r>
3549 </w:p>
3550 </w:body>
3551</w:document>"#;
3552
3553 let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3554<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3555 <Relationship Id="rId20" Target="https://rust-lang.org"
3556 Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
3557 TargetMode="External"/>
3558</Relationships>"#;
3559
3560 let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3561<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3562 <w:abstractNum w:abstractNumId="0">
3563 <w:lvl w:ilvl="0">
3564 <w:start w:val="1"/>
3565 <w:numFmt w:val="decimal"/>
3566 </w:lvl>
3567 </w:abstractNum>
3568 <w:num w:numId="1">
3569 <w:abstractNumId w:val="0"/>
3570 </w:num>
3571</w:numbering>"#;
3572
3573 let zip_data = make_docx_with_rels_and_numbering(doc_xml, rels_xml, numbering_xml);
3574 let tmp = tempfile::NamedTempFile::new().unwrap();
3575 std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3576 let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3577 let blocks = reader.read_blocks().unwrap();
3578
3579 match &blocks[0] {
3581 DocumentBlock::List(list) => {
3582 assert!(list.ordered, "should be ordered (decimal)");
3583 assert_eq!(list.items.len(), 2);
3584
3585 match &list.items[0].blocks[0] {
3587 DocumentBlock::Paragraph(runs) => {
3588 assert_eq!(runs.len(), 2);
3589 assert_eq!(runs[0].text, "See ");
3590 assert!(runs[0].hyperlink.is_none());
3591 assert_eq!(runs[1].text, "Rust lang");
3592 assert_eq!(runs[1].hyperlink.as_deref(), Some("https://rust-lang.org"),);
3593 }
3594 other => panic!("expected Paragraph, got {other:?}"),
3595 }
3596
3597 match &list.items[1].blocks[0] {
3599 DocumentBlock::Paragraph(runs) => {
3600 assert_eq!(runs[0].text, "Plain item");
3601 assert!(runs[0].hyperlink.is_none());
3602 }
3603 other => panic!("expected Paragraph, got {other:?}"),
3604 }
3605 }
3606 other => panic!("expected List, got {other:?}"),
3607 }
3608 }
3609
3610 #[test]
3615 fn two_level_list_nests_ilvl_1_in_ilvl_0() {
3616 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3618<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3619 <w:body>
3620 <w:p>
3621 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3622 <w:r><w:t>Top A</w:t></w:r>
3623 </w:p>
3624 <w:p>
3625 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3626 <w:r><w:t>Top B</w:t></w:r>
3627 </w:p>
3628 <w:p>
3629 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3630 <w:r><w:t>Nested under B</w:t></w:r>
3631 </w:p>
3632 </w:body>
3633</w:document>"#;
3634 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3635 let blocks = reader.read_blocks().unwrap();
3636 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3637 match &blocks[0] {
3638 DocumentBlock::List(list) => {
3639 assert_eq!(list.items.len(), 2, "items = {:?}", list.items);
3641
3642 assert!(list.items[0].nested.is_none());
3644
3645 let nested = list.items[1]
3647 .nested
3648 .as_ref()
3649 .expect("Top B should have nested list");
3650 assert_eq!(nested.items.len(), 1);
3651 match &nested.items[0].blocks[0] {
3652 DocumentBlock::Paragraph(runs) => {
3653 assert_eq!(runs[0].text, "Nested under B");
3654 }
3655 other => panic!("expected Paragraph, got {other:?}"),
3656 }
3657 }
3658 other => panic!("expected List, got {other:?}"),
3659 }
3660 }
3661
3662 #[test]
3663 fn three_level_list_nests_correctly() {
3664 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3666<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3667 <w:body>
3668 <w:p>
3669 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3670 <w:r><w:t>Level 0</w:t></w:r>
3671 </w:p>
3672 <w:p>
3673 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3674 <w:r><w:t>Level 1</w:t></w:r>
3675 </w:p>
3676 <w:p>
3677 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="2"/></w:numPr></w:pPr>
3678 <w:r><w:t>Level 2</w:t></w:r>
3679 </w:p>
3680 <w:p>
3681 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3682 <w:r><w:t>Second top</w:t></w:r>
3683 </w:p>
3684 </w:body>
3685</w:document>"#;
3686 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3687 let blocks = reader.read_blocks().unwrap();
3688 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3689 match &blocks[0] {
3690 DocumentBlock::List(list) => {
3691 assert_eq!(list.items.len(), 2);
3693
3694 let item0 = &list.items[0];
3696 let nested1 = item0.nested.as_ref().expect("Level 0 should have nested");
3697 assert_eq!(nested1.items.len(), 1);
3698
3699 let nested2 = nested1.items[0]
3701 .nested
3702 .as_ref()
3703 .expect("Level 1 should have nested");
3704 assert_eq!(nested2.items.len(), 1);
3705
3706 match &nested2.items[0].blocks[0] {
3708 DocumentBlock::Paragraph(runs) => {
3709 assert_eq!(runs[0].text, "Level 2");
3710 }
3711 other => panic!("expected Paragraph, got {other:?}"),
3712 }
3713
3714 assert!(list.items[1].nested.is_none());
3716 }
3717 other => panic!("expected List, got {other:?}"),
3718 }
3719 }
3720
3721 #[test]
3722 fn flat_list_with_multiple_ilvl_0() {
3723 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3725<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3726 <w:body>
3727 <w:p>
3728 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3729 <w:r><w:t>A</w:t></w:r>
3730 </w:p>
3731 <w:p>
3732 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3733 <w:r><w:t>B</w:t></w:r>
3734 </w:p>
3735 <w:p>
3736 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3737 <w:r><w:t>C</w:t></w:r>
3738 </w:p>
3739 <w:p>
3740 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3741 <w:r><w:t>D</w:t></w:r>
3742 </w:p>
3743 <w:p>
3744 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3745 <w:r><w:t>E</w:t></w:r>
3746 </w:p>
3747 </w:body>
3748</w:document>"#;
3749 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3750 let blocks = reader.read_blocks().unwrap();
3751 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3752 match &blocks[0] {
3753 DocumentBlock::List(list) => {
3754 assert_eq!(list.items.len(), 5);
3755 for item in &list.items {
3756 assert!(item.nested.is_none(), "flat items should not have nested");
3757 }
3758 let texts: Vec<&str> = list
3760 .items
3761 .iter()
3762 .map(|item| match &item.blocks[0] {
3763 DocumentBlock::Paragraph(runs) => runs[0].text.as_str(),
3764 _ => panic!("expected Paragraph"),
3765 })
3766 .collect();
3767 assert_eq!(texts, vec!["A", "B", "C", "D", "E"]);
3768 }
3769 other => panic!("expected List, got {other:?}"),
3770 }
3771 }
3772
3773 #[test]
3774 fn list_breaks_at_non_list_paragraph() {
3775 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3777<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3778 <w:body>
3779 <w:p>
3780 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3781 <w:r><w:t>List 1 top</w:t></w:r>
3782 </w:p>
3783 <w:p>
3784 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3785 <w:r><w:t>List 1 nested</w:t></w:r>
3786 </w:p>
3787 <w:p>
3788 <w:r><w:t>Separator paragraph</w:t></w:r>
3789 </w:p>
3790 <w:p>
3791 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3792 <w:r><w:t>List 2 top</w:t></w:r>
3793 </w:p>
3794 </w:body>
3795</w:document>"#;
3796 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3797 let blocks = reader.read_blocks().unwrap();
3798 assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
3800
3801 match &blocks[0] {
3803 DocumentBlock::List(list) => {
3804 assert_eq!(list.items.len(), 1);
3805 let nested = list.items[0]
3806 .nested
3807 .as_ref()
3808 .expect("first list item should have nested");
3809 assert_eq!(nested.items.len(), 1);
3810 }
3811 other => panic!("expected List, got {other:?}"),
3812 }
3813
3814 match &blocks[1] {
3816 DocumentBlock::Paragraph(runs) => {
3817 assert_eq!(runs[0].text, "Separator paragraph");
3818 }
3819 other => panic!("expected Paragraph, got {other:?}"),
3820 }
3821
3822 match &blocks[2] {
3824 DocumentBlock::List(list) => {
3825 assert_eq!(list.items.len(), 1);
3826 assert!(list.items[0].nested.is_none());
3827 }
3828 other => panic!("expected List, got {other:?}"),
3829 }
3830 }
3831
3832 #[test]
3833 fn ilvl_decrease_creates_separate_branch() {
3834 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3836<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3837 <w:body>
3838 <w:p>
3839 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3840 <w:r><w:t>Branch A</w:t></w:r>
3841 </w:p>
3842 <w:p>
3843 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3844 <w:r><w:t>Branch A child</w:t></w:r>
3845 </w:p>
3846 <w:p>
3847 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3848 <w:r><w:t>Branch B</w:t></w:r>
3849 </w:p>
3850 </w:body>
3851</w:document>"#;
3852 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3853 let blocks = reader.read_blocks().unwrap();
3854 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3855 match &blocks[0] {
3856 DocumentBlock::List(list) => {
3857 assert_eq!(list.items.len(), 2);
3858
3859 let nested = list.items[0]
3861 .nested
3862 .as_ref()
3863 .expect("Branch A should have nested");
3864 assert_eq!(nested.items.len(), 1);
3865 match &nested.items[0].blocks[0] {
3866 DocumentBlock::Paragraph(runs) => {
3867 assert_eq!(runs[0].text, "Branch A child");
3868 }
3869 other => panic!("expected Paragraph, got {other:?}"),
3870 }
3871
3872 assert!(list.items[1].nested.is_none());
3874 match &list.items[1].blocks[0] {
3875 DocumentBlock::Paragraph(runs) => {
3876 assert_eq!(runs[0].text, "Branch B");
3877 }
3878 other => panic!("expected Paragraph, got {other:?}"),
3879 }
3880 }
3881 other => panic!("expected List, got {other:?}"),
3882 }
3883 }
3884
3885 #[test]
3886 fn multiple_siblings_at_nested_level() {
3887 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3889<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3890 <w:body>
3891 <w:p>
3892 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3893 <w:r><w:t>Parent</w:t></w:r>
3894 </w:p>
3895 <w:p>
3896 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3897 <w:r><w:t>Child 1</w:t></w:r>
3898 </w:p>
3899 <w:p>
3900 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3901 <w:r><w:t>Child 2</w:t></w:r>
3902 </w:p>
3903 <w:p>
3904 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3905 <w:r><w:t>Sibling</w:t></w:r>
3906 </w:p>
3907 </w:body>
3908</w:document>"#;
3909 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3910 let blocks = reader.read_blocks().unwrap();
3911 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3912 match &blocks[0] {
3913 DocumentBlock::List(list) => {
3914 assert_eq!(list.items.len(), 2);
3915
3916 let nested = list.items[0]
3918 .nested
3919 .as_ref()
3920 .expect("Parent should have nested");
3921 assert_eq!(nested.items.len(), 2);
3922
3923 match &nested.items[0].blocks[0] {
3924 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Child 1"),
3925 other => panic!("expected Paragraph, got {other:?}"),
3926 }
3927 match &nested.items[1].blocks[0] {
3928 DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Child 2"),
3929 other => panic!("expected Paragraph, got {other:?}"),
3930 }
3931
3932 assert!(list.items[1].nested.is_none());
3934 }
3935 other => panic!("expected List, got {other:?}"),
3936 }
3937 }
3938
3939 #[test]
3944 fn ilvl_jump_0_to_2_attaches_to_existing_ancestor() {
3945 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3950<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3951 <w:body>
3952 <w:p>
3953 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3954 <w:r><w:t>Top level</w:t></w:r>
3955 </w:p>
3956 <w:p>
3957 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="2"/></w:numPr></w:pPr>
3958 <w:r><w:t>Deep nested</w:t></w:r>
3959 </w:p>
3960 </w:body>
3961</w:document>"#;
3962 let mut reader = DocxSaxReader::from_reader(&xml[..]);
3963 let blocks = reader.read_blocks().unwrap();
3964 assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3965 match &blocks[0] {
3966 DocumentBlock::List(list) => {
3967 assert_eq!(list.items.len(), 1, "one top-level item");
3968 let top = &list.items[0];
3969 let nested1 = top.nested.as_ref().expect("top item should have nested");
3971 assert_eq!(nested1.items.len(), 1, "one item in nested");
3974 match &nested1.items[0].blocks[0] {
3975 DocumentBlock::Paragraph(runs) => {
3976 assert_eq!(runs[0].text, "Deep nested");
3977 }
3978 other => panic!("expected Paragraph, got {other:?}"),
3979 }
3980 }
3981 other => panic!("expected List, got {other:?}"),
3982 }
3983 }
3984
3985 #[test]
3986 fn ilvl_jump_0_to_3_with_sibling_at_level_1() {
3987 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3991<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3992 <w:body>
3993 <w:p>
3994 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3995 <w:r><w:t>Root</w:t></w:r>
3996 </w:p>
3997 <w:p>
3998 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3999 <w:r><w:t>Level 1</w:t></w:r>
4000 </w:p>
4001 <w:p>
4002 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="3"/></w:numPr></w:pPr>
4003 <w:r><w:t>Very deep</w:t></w:r>
4004 </w:p>
4005 </w:body>
4006</w:document>"#;
4007 let mut reader = DocxSaxReader::from_reader(&xml[..]);
4008 let blocks = reader.read_blocks().unwrap();
4009 assert_eq!(blocks.len(), 1);
4010 match &blocks[0] {
4011 DocumentBlock::List(list) => {
4012 assert_eq!(list.items.len(), 1);
4013 let n1 = list.items[0].nested.as_ref().expect("level 1");
4015 assert_eq!(n1.items.len(), 1);
4016 let n2 = n1.items[0].nested.as_ref().expect("level 2 nested");
4021 assert_eq!(n2.items.len(), 1);
4022 let deep_text = match &n2.items[0].blocks[0] {
4024 DocumentBlock::Paragraph(runs) => runs[0].text.clone(),
4025 other => panic!("expected Paragraph, got {other:?}"),
4026 };
4027 assert_eq!(deep_text, "Very deep");
4028 }
4029 other => panic!("expected List, got {other:?}"),
4030 }
4031 }
4032
4033 #[test]
4034 fn ilvl_jump_with_sibling_after() {
4035 let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4037<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4038 <w:body>
4039 <w:p>
4040 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
4041 <w:r><w:t>First</w:t></w:r>
4042 </w:p>
4043 <w:p>
4044 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="2"/></w:numPr></w:pPr>
4045 <w:r><w:t>Deep child</w:t></w:r>
4046 </w:p>
4047 <w:p>
4048 <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
4049 <w:r><w:t>Second</w:t></w:r>
4050 </w:p>
4051 </w:body>
4052</w:document>"#;
4053 let mut reader = DocxSaxReader::from_reader(&xml[..]);
4054 let blocks = reader.read_blocks().unwrap();
4055 assert_eq!(blocks.len(), 1);
4056 match &blocks[0] {
4057 DocumentBlock::List(list) => {
4058 assert_eq!(list.items.len(), 2, "two top-level items");
4059 assert!(list.items[0].nested.is_some(), "first should have nested");
4061 assert!(list.items[1].nested.is_none(), "second should be flat");
4063 match &list.items[1].blocks[0] {
4064 DocumentBlock::Paragraph(runs) => {
4065 assert_eq!(runs[0].text, "Second");
4066 }
4067 other => panic!("expected Paragraph, got {other:?}"),
4068 }
4069 }
4070 other => panic!("expected List, got {other:?}"),
4071 }
4072 }
4073}