Skip to main content

rust_docx/parse/
document.rs

1use crate::model::{
2    DocNode, ParagraphChildNode, ParagraphNode, ParagraphNodeType, TextRunNode, TextRunNodeType,
3};
4
5use super::body::body_xml;
6use super::context::ParseContext;
7use super::paragraph::parse_paragraph;
8use super::table::parse_table;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct BodyTokenRange {
12    pub start: usize,
13    pub end: usize,
14    pub kind: BodyTokenKind,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BodyTokenKind {
19    Table,
20    Paragraph,
21}
22
23pub fn extract_body_token_ranges(body_xml: &str) -> Vec<BodyTokenRange> {
24    let mut ranges = Vec::new();
25    for (name, kind) in [
26        ("w:tbl", BodyTokenKind::Table),
27        ("w:p", BodyTokenKind::Paragraph),
28    ] {
29        ranges.extend(
30            crate::xml::extract_balanced_tag_ranges(body_xml, name)
31                .into_iter()
32                .map(|range| BodyTokenRange {
33                    start: range.start,
34                    end: range.end,
35                    kind,
36                }),
37        );
38    }
39    ranges.sort_by_key(|range| range.start);
40    let mut covered_end = 0;
41    ranges.retain(|range| {
42        if range.end <= covered_end {
43            return false;
44        }
45        covered_end = range.end;
46        true
47    });
48    ranges
49}
50
51pub fn is_go_back_bookmark_paragraph(paragraph_xml: &str) -> bool {
52    if !contains_case_insensitive(paragraph_xml, "w:name=\"_GoBack\"") {
53        return false;
54    }
55
56    !contains_case_insensitive_tag(paragraph_xml, "w:r")
57        && !contains_case_insensitive_tag(paragraph_xml, "w:drawing")
58}
59
60pub fn parse_document_xml(
61    document_xml: &str,
62    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
63) -> Vec<DocNode> {
64    let body = body_xml(document_xml);
65    parse_ranges(body, extract_body_token_ranges(body), context)
66}
67
68fn parse_token(
69    body: &str,
70    token: &BodyTokenRange,
71    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
72) -> Option<DocNode> {
73    let xml = &body[token.start..token.end];
74    if token.kind == BodyTokenKind::Table {
75        Some(DocNode::Table(parse_table(xml, context)))
76    } else if !is_go_back_bookmark_paragraph(xml) {
77        Some(DocNode::Paragraph(parse_paragraph(xml, context)))
78    } else {
79        None
80    }
81}
82
83fn parse_ranges(
84    body: &str,
85    ranges: Vec<BodyTokenRange>,
86    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
87) -> Vec<DocNode> {
88    let mut nodes = Vec::with_capacity(ranges.len());
89    for token in ranges {
90        if let Some(node) = parse_token(body, &token, context) {
91            nodes.push(node);
92        }
93    }
94    nonempty_nodes(nodes)
95}
96
97fn nonempty_nodes(mut nodes: Vec<DocNode>) -> Vec<DocNode> {
98    if nodes.is_empty() {
99        nodes.push(DocNode::Paragraph(ParagraphNode {
100            review_content: None,
101            r#type: ParagraphNodeType::Paragraph,
102            block_id: None,
103            children: vec![ParagraphChildNode::Text(TextRunNode {
104                r#type: TextRunNodeType::Text,
105                text: String::new(),
106                style: None,
107                link: None,
108                note_reference: None,
109                field_instruction: None,
110            })],
111            style: None,
112            paragraph_mark_style: None,
113            source_paragraph_mark_style: None,
114            source_paragraph_mark_formatting: None,
115            source_paragraph_mark_properties_xml: None,
116            paragraph_mark_deleted: None,
117            source_xml: None,
118            source_text_patch: None,
119            source_run_provenance: None,
120        }));
121    }
122
123    nodes
124}
125
126#[cfg(not(target_arch = "wasm32"))]
127pub(crate) fn parse_document_xml_parallel(
128    document: &str,
129    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
130    indexes: &crate::xml_index::Scope<'_>,
131) -> Vec<DocNode> {
132    let body = body_xml(document);
133    let ranges = extract_body_token_ranges(body);
134    let bytes = ranges
135        .iter()
136        .map(|range| range.end - range.start)
137        .sum::<usize>();
138    let largest = ranges
139        .iter()
140        .map(|range| range.end - range.start)
141        .max()
142        .unwrap_or(0);
143    let pool = (bytes.saturating_sub(largest) >= 16 * 1024 && ranges.len() >= 16)
144        .then(super::namespaces::worker_pool)
145        .flatten();
146    let Some(pool) = pool else {
147        return parse_ranges(body, ranges, context);
148    };
149    let workers = pool.current_num_threads() + 1;
150    let mut order: Vec<_> = (0..ranges.len()).collect();
151    order.sort_unstable_by_key(|id| std::cmp::Reverse(ranges[*id].end - ranges[*id].start));
152    let mut weights = vec![0usize; workers];
153    let mut assignments = vec![0usize; ranges.len()];
154    for id in order {
155        let worker = (0..workers).min_by_key(|worker| weights[*worker]).unwrap();
156        assignments[id] = worker;
157        weights[worker] += ranges[id].end - ranges[id].start;
158    }
159    let mut results: Vec<Option<(Option<DocNode>, Vec<String>)>> =
160        (0..ranges.len()).map(|_| None).collect();
161    let mut groups: Vec<Vec<_>> = (0..workers).map(|_| Vec::new()).collect();
162    for ((range, result), worker) in ranges.iter().zip(results.iter_mut()).zip(assignments) {
163        groups[worker].push((range, result));
164    }
165    let mut groups = groups.into_iter();
166    let caller = groups.next().unwrap();
167    let relationships = &context.relationships;
168    let content_types = &context.content_types;
169    let styles = context.styles();
170    let parts = context.parts;
171    let binary_assets = context.binary_assets;
172    let mut earlier_warnings = std::mem::take(&mut *context.warnings.borrow_mut());
173    pool.in_place_scope(|scope| {
174        for group in groups {
175            if group.is_empty() {
176                continue;
177            }
178            let snapshot = indexes.snapshot();
179            scope.spawn(move |_| {
180                let _indexes = snapshot.enter();
181                let _styles = super::style::run_style_cache_scope();
182                super::style::bind_style_cache(styles);
183                let context = ParseContext {
184                    relationships: relationships.clone(),
185                    content_types: content_types.clone(),
186                    parts,
187                    binary_assets,
188                    style_sheet: styles,
189                    warnings: std::cell::RefCell::new(Vec::new()),
190                };
191                for (range, result) in group {
192                    let node = parse_token(body, range, &context);
193                    *result = Some((node, std::mem::take(&mut *context.warnings.borrow_mut())));
194                }
195            });
196        }
197        for (range, result) in caller {
198            let node = parse_token(body, range, context);
199            *result = Some((node, std::mem::take(&mut *context.warnings.borrow_mut())));
200        }
201    });
202    let mut nodes = Vec::with_capacity(results.len());
203    for result in results {
204        let (node, warnings) = result.expect("joined body task");
205        if let Some(node) = node {
206            nodes.push(node);
207        }
208        earlier_warnings.extend(warnings);
209    }
210    *context.warnings.borrow_mut() = earlier_warnings;
211    nonempty_nodes(nodes)
212}
213
214fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
215    find_case_insensitive_at(haystack, 0, needle).is_some()
216}
217
218fn contains_case_insensitive_tag(haystack: &str, tag_name: &str) -> bool {
219    find_case_insensitive_at(haystack, 0, &format!("<{tag_name}")).is_some()
220        || find_case_insensitive_at(haystack, 0, &format!("<{tag_name}/")).is_some()
221        || find_case_insensitive_at(haystack, 0, &format!("<{tag_name} ")).is_some()
222}
223
224fn find_case_insensitive_at(haystack: &str, start: usize, needle: &str) -> Option<usize> {
225    crate::xml::find_ignore_ascii_case(haystack, start, needle)
226}
227
228#[cfg(test)]
229mod tests {
230    use std::cell::RefCell;
231    use std::collections::HashMap;
232
233    use super::*;
234    use crate::parse::context::{ContentTypeLookup, ParseContext, ParsedStyleSheet};
235
236    #[test]
237    fn extract_body_xml_returns_inner_content() {
238        let xml = "<w:document><w:body><w:p/></w:body></w:document>";
239        assert_eq!(super::super::body::extract_body_xml(xml), "<w:p/>");
240    }
241
242    #[test]
243    fn is_go_back_bookmark_paragraph_detects_hidden_bookmark() {
244        let xml = r#"<w:p><w:bookmarkStart w:name="_GoBack"/></w:p>"#;
245        assert!(is_go_back_bookmark_paragraph(xml));
246        let with_run = r#"<w:p><w:bookmarkStart w:name="_GoBack"/><w:r><w:t>x</w:t></w:r></w:p>"#;
247        assert!(!is_go_back_bookmark_paragraph(with_run));
248    }
249
250    #[test]
251    fn parse_document_xml_skips_go_back_paragraph() {
252        let xml = r#"<w:body><w:p><w:bookmarkStart w:name="_GoBack"/></w:p><w:p><w:r><w:t>Hi</w:t></w:r></w:p></w:body>"#;
253        let context = ParseContext {
254            relationships: HashMap::new(),
255            content_types: ContentTypeLookup::default(),
256            parts: &HashMap::new(),
257            binary_assets: &HashMap::new(),
258            style_sheet: ParsedStyleSheet::empty(),
259            warnings: RefCell::new(Vec::new()),
260        };
261        let nodes = parse_document_xml(xml, &context);
262        assert_eq!(nodes.len(), 1);
263    }
264
265    #[test]
266    fn drawing_text_stays_out_of_the_anchor_paragraph() {
267        let xml = r#"<w:body><w:p><w:r><w:t>Before</w:t></w:r><w:r><w:drawing><wpg:wgp><wps:wsp><wps:txbx><w:txbxContent><w:p><w:r><w:t>Shape story</w:t></w:r></w:p></w:txbxContent></wps:txbx></wps:wsp></wpg:wgp></w:drawing></w:r><w:r><w:t>After</w:t></w:r></w:p></w:body>"#;
268        let context = ParseContext {
269            relationships: HashMap::new(),
270            content_types: ContentTypeLookup::default(),
271            parts: &HashMap::new(),
272            binary_assets: &HashMap::new(),
273            style_sheet: ParsedStyleSheet::empty(),
274            warnings: RefCell::new(Vec::new()),
275        };
276        let nodes = parse_document_xml(xml, &context);
277        let DocNode::Paragraph(paragraph) = &nodes[0] else {
278            panic!("paragraph")
279        };
280        let text: String = paragraph
281            .children
282            .iter()
283            .filter_map(|child| match child {
284                ParagraphChildNode::Text(run) => Some(run.text.as_str()),
285                _ => None,
286            })
287            .collect();
288        assert_eq!(text, "BeforeAfter");
289        assert_eq!(
290            paragraph.source_xml.as_deref(),
291            Some(&xml[8..xml.len() - 9])
292        );
293
294        let with_control = xml.replace("<w:r><w:t>Shape story</w:t></w:r>",
295            "<w:sdt><w:sdtPr><w:text/></w:sdtPr><w:sdtContent><w:r><w:t>Shape story</w:t></w:r></w:sdtContent></w:sdt>");
296        let nodes = parse_document_xml(&with_control, &context);
297        let DocNode::Paragraph(paragraph) = &nodes[0] else {
298            panic!("paragraph")
299        };
300        assert!(!paragraph
301            .children
302            .iter()
303            .any(|child| matches!(child, ParagraphChildNode::FormField(_))));
304        let nodes = parse_document_xml("<w:body><w:p><w:sdt><w:sdtPr><w:text/></w:sdtPr><w:sdtContent><w:r><w:t>Body control</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body>", &context);
305        let DocNode::Paragraph(paragraph) = &nodes[0] else {
306            panic!("paragraph")
307        };
308        assert!(paragraph
309            .children
310            .iter()
311            .any(|child| matches!(child, ParagraphChildNode::FormField(_))));
312    }
313}
314
315#[cfg(all(test, not(target_arch = "wasm32")))]
316mod parallel_tests {
317    #[test]
318    fn balanced_body_preserves_nodes_and_warning_order() {
319        let content = "content &amp; detail ".repeat(40);
320        let mut xml = "<w:document><w:body>".to_owned();
321        for index in 0..80 {
322            xml.push_str(&format!(
323                "<w:p><w:r><w:t>{index} {content}</w:t></w:r></w:p>"
324            ));
325            if index % 9 == 0 {
326                xml.push_str("<w:p><m:oMath><m:unknown/></m:oMath></w:p>");
327            }
328        }
329        xml.push_str("</w:body></w:document>");
330        let mut parts = std::collections::HashMap::new();
331        parts.insert(
332            "body".into(),
333            crate::package::OoxmlPart {
334                name: "body".into(),
335                content: xml.clone(),
336            },
337        );
338        let assets = std::collections::HashMap::new();
339        let context = crate::parse::context::ParseContext {
340            relationships: Default::default(),
341            content_types: Default::default(),
342            parts: &parts,
343            binary_assets: &assets,
344            style_sheet: crate::parse::context::ParsedStyleSheet::empty(),
345            warnings: std::cell::RefCell::new(vec!["prior warning".into()]),
346        };
347        let indexes =
348            crate::xml_index::scope([("body", xml.as_str())].into_iter(), Default::default());
349        let serial = super::parse_document_xml(&xml, &context);
350        let warnings = context.warnings.replace(vec!["prior warning".into()]);
351        let parallel = super::parse_document_xml_parallel(&xml, &context, &indexes);
352        assert_eq!(
353            serde_json::to_value(serial).unwrap(),
354            serde_json::to_value(parallel).unwrap()
355        );
356        assert_eq!(warnings, *context.warnings.borrow());
357    }
358}