Skip to main content

rust_docx/parse/
paragraph.rs

1use crate::model::{
2    FormFieldCheckboxSizeMode, FormFieldCheckboxWidgetSettings, FormFieldDropdownWidgetSettings,
3    FormFieldOption, FormFieldRunNode, FormFieldRunNodeType, FormFieldSourceKind,
4    FormFieldSourcePreview,
5    FormFieldTextWidgetSettings, FormFieldType, FormFieldWidgetSettings, NoteReference,
6    NoteReferenceKind, ParagraphChildNode, ParagraphNode, ParagraphNodeType,
7    ParagraphSourceTextPatch, ParagraphSourceTextPatchRun, SharedTextStyle, TextRunNode,
8    TextRunNodeType, TextStyle,
9};
10use crate::parse::context::ParseContext;
11use crate::parse::images::{parse_run_image_block, parse_run_image_tokens};
12use crate::parse::re;
13use crate::parse::style::parse_run_style_in_table;
14use crate::parse::util::{
15    active_xml as prefer_alternate_content_choice, decode_hex_code_point, decode_xml_attribute,
16    is_windows_metafile_content_type,
17    normalize_legacy_form_display_value, on_off_value_to_boolean, parse_on_off_tag_value,
18    parse_relationships_from_parts, strip_text_box_content, svg_data_uri,
19};
20use crate::xml::{
21    decode_xml_entities, extract_balanced_tag_blocks, extract_balanced_tag_ranges,
22    extract_direct_child_tag_ranges, get_attribute, get_attribute_ref, parse_integer_attribute,
23    parse_on_off_attribute,
24};
25
26#[derive(Clone, Debug)]
27pub struct ParagraphRunToken {
28    pub xml: String,
29    pub start: usize,
30    pub end: usize,
31    pub link: Option<String>,
32    pub text_links: Vec<(usize, Option<String>)>,
33}
34
35#[derive(Clone, Debug)]
36struct RunToken<'a> {
37    xml: std::borrow::Cow<'a, str>,
38    start: usize,
39    end: usize,
40    link: Option<String>,
41    text_links: Vec<(usize, Option<String>)>,
42    empty_fields: Vec<(usize, String)>,
43}
44
45#[derive(Clone, Debug)]
46pub struct ParsedRunTextToken {
47    pub text: String,
48    pub source_start: usize,
49    pub note_reference: Option<NoteReference>,
50}
51
52struct IndexedPlainRun<'a> {
53    properties: &'a str,
54    scripts: u8,
55    tokens: Vec<ParsedRunTextToken>,
56}
57
58#[derive(Default)]
59struct PlainRunBuilder<'a> {
60    properties: Option<&'a str>,
61    properties_end: usize,
62    seen_child: bool,
63    scripts: u8,
64    tokens: Vec<ParsedRunTextToken>,
65}
66
67fn plain_property_leaf(name: &str) -> bool {
68    matches!(
69        name,
70        "w:rStyle"
71            | "w:rFonts"
72            | "w:b"
73            | "w:bCs"
74            | "w:i"
75            | "w:iCs"
76            | "w:caps"
77            | "w:smallCaps"
78            | "w:strike"
79            | "w:dstrike"
80            | "w:outline"
81            | "w:shadow"
82            | "w:emboss"
83            | "w:imprint"
84            | "w:noProof"
85            | "w:snapToGrid"
86            | "w:vanish"
87            | "w:webHidden"
88            | "w:color"
89            | "w:spacing"
90            | "w:w"
91            | "w:kern"
92            | "w:position"
93            | "w:sz"
94            | "w:szCs"
95            | "w:highlight"
96            | "w:u"
97            | "w:effect"
98            | "w:bdr"
99            | "w:shd"
100            | "w:fitText"
101            | "w:vertAlign"
102            | "w:rtl"
103            | "w:cs"
104            | "w:em"
105            | "w:lang"
106            | "w:eastAsianLayout"
107            | "w:specVanish"
108            | "w:oMath"
109            | "w14:ligatures"
110            | "w14:contextualAlternates"
111    )
112}
113
114impl<'a> PlainRunBuilder<'a> {
115    fn visit(&mut self, name: &str, xml: &'a str, start: usize, content: Option<&str>) -> bool {
116        if start == 0 {
117            return name == "w:r";
118        }
119        self.seen_child = true;
120        if start < self.properties_end {
121            return content.is_none() && plain_property_leaf(name);
122        }
123        let text = match name {
124            "w:rPr" => {
125                if self.properties.replace(xml).is_some() {
126                    return false;
127                }
128                self.properties_end = start + xml.len();
129                return true;
130            }
131            "w:t" => {
132                // Paired text preserves the legacy script scanner's boundaries.
133                let Some(text) = content else {
134                    return false;
135                };
136                if memchr::memchr(b'<', text.as_bytes()).is_some() {
137                    return false;
138                }
139                let text = if memchr::memchr2(b'\r', b'\n', text.as_bytes()).is_some() {
140                    decode_xml_entities(&text.replace("\r\n", " ").replace(['\r', '\n'], " "))
141                } else {
142                    decode_xml_entities(text)
143                };
144                if !text.is_ascii() {
145                    for ch in text.chars() {
146                        match ch as u32 {
147                            0x2e80..=0x9fff | 0xac00..=0xd7af => self.scripts |= 1,
148                            0x0590..=0x08ff | 0xfb1d..=0xfefc => self.scripts |= 2,
149                            _ => {}
150                        }
151                    }
152                }
153                text
154            }
155            "w:tab" if content.is_none() => "\t".into(),
156            "w:br" | "w:cr" if content.is_none() => "\n".into(),
157            "w:noBreakHyphen" if content.is_none() => "\u{2011}".into(),
158            "w:softHyphen" if content.is_none() => "\u{00ad}".into(),
159            "w:instrText" => return content.is_none_or(|text| !text.contains('<')),
160            "w:lastRenderedPageBreak" if content.is_none() => return true,
161            _ => return false,
162        };
163        self.tokens.push(ParsedRunTextToken {
164            text,
165            source_start: start,
166            note_reference: None,
167        });
168        true
169    }
170
171    fn finish(self) -> Option<IndexedPlainRun<'a>> {
172        self.seen_child.then_some(IndexedPlainRun {
173            properties: self.properties.unwrap_or(""),
174            scripts: self.scripts,
175            tokens: self.tokens,
176        })
177    }
178}
179
180fn contains_xml_control(xml: &str) -> bool {
181    memchr::memchr2_iter(b'?', b'!', xml.as_bytes())
182        .any(|position| position != 0 && xml.as_bytes()[position - 1] == b'<')
183}
184
185fn indexed_plain_run(xml: &str) -> Option<IndexedPlainRun<'_>> {
186    if contains_xml_control(xml) {
187        return None;
188    }
189    let mut run = PlainRunBuilder::default();
190    let complete = crate::xml_index::for_each_element(xml, |element| {
191        run.visit(
192            element.name,
193            element.xml,
194            element.source_start,
195            element.content(),
196        )
197    })?;
198    complete.then(|| run.finish()).flatten()
199}
200
201struct IndexedPlainParagraphRun<'a> {
202    xml: &'a str,
203    run: IndexedPlainRun<'a>,
204}
205
206fn indexed_plain_paragraph(xml: &str) -> Option<Vec<IndexedPlainParagraphRun<'_>>> {
207    if contains_xml_control(xml) {
208        return None;
209    }
210    let mut runs = Vec::new();
211    let mut active: Option<(usize, usize, &str, PlainRunBuilder<'_>)> = None;
212    let mut properties_end = 0;
213    let mut seen_properties = false;
214    let complete = crate::xml_index::for_each_element(xml, |element| {
215        if let Some((start, end, _, run)) = active.as_mut() {
216            if element.source_start < *end {
217                return run.visit(
218                    element.name,
219                    element.xml,
220                    element.source_start - *start,
221                    element.content(),
222                );
223            }
224            let (_, _, run_xml, run) = active.take().unwrap();
225            let Some(run) = run.finish() else {
226                return false;
227            };
228            runs.push(IndexedPlainParagraphRun { xml: run_xml, run });
229        }
230        if element.source_start == 0 {
231            return element.name == "w:p";
232        }
233        if element.source_start < properties_end {
234            return matches!(element.name, "w:rPr" | "w:numPr" | "w:pBdr" | "w:tabs")
235                || (element.content().is_none()
236                    && (plain_property_leaf(element.name)
237                        || matches!(
238                            element.name,
239                            "w:pStyle"
240                                | "w:keepNext"
241                                | "w:keepLines"
242                                | "w:pageBreakBefore"
243                                | "w:framePr"
244                                | "w:widowControl"
245                                | "w:ilvl"
246                                | "w:numId"
247                                | "w:suppressLineNumbers"
248                                | "w:top"
249                                | "w:left"
250                                | "w:bottom"
251                                | "w:right"
252                                | "w:between"
253                                | "w:bar"
254                                | "w:tab"
255                                | "w:suppressAutoHyphens"
256                                | "w:kinsoku"
257                                | "w:wordWrap"
258                                | "w:overflowPunct"
259                                | "w:topLinePunct"
260                                | "w:autoSpaceDE"
261                                | "w:autoSpaceDN"
262                                | "w:bidi"
263                                | "w:adjustRightInd"
264                                | "w:ind"
265                                | "w:contextualSpacing"
266                                | "w:mirrorIndents"
267                                | "w:suppressOverlap"
268                                | "w:jc"
269                                | "w:textDirection"
270                                | "w:textAlignment"
271                                | "w:textboxTightWrap"
272                                | "w:outlineLvl"
273                                | "w:divId"
274                                | "w:cnfStyle"
275                                | "w:del"
276                                | "w:ins"
277                        )));
278        }
279        match element.name {
280            "w:pPr" if !seen_properties => {
281                seen_properties = true;
282                properties_end = element.source_start + element.xml.len();
283                true
284            }
285            "w:r" => {
286                let mut run = PlainRunBuilder::default();
287                if !run.visit(element.name, element.xml, 0, element.content()) {
288                    return false;
289                }
290                active = Some((
291                    element.source_start,
292                    element.source_start + element.xml.len(),
293                    element.xml,
294                    run,
295                ));
296                true
297            }
298            _ => false,
299        }
300    })?;
301    if !complete {
302        return None;
303    }
304    if let Some((_, _, run_xml, run)) = active {
305        runs.push(IndexedPlainParagraphRun {
306            xml: run_xml,
307            run: run.finish()?,
308        });
309    }
310    Some(runs)
311}
312
313pub(crate) fn run_text_xml(run_xml: &str) -> std::borrow::Cow<'_, str> {
314    let mut active = prefer_alternate_content_choice(run_xml);
315    for range in extract_balanced_tag_ranges(&active, "w:txbxContent")
316        .into_iter()
317        .rev()
318    {
319        active
320            .to_mut()
321            .replace_range(range.start..range.end, &" ".repeat(range.end - range.start));
322    }
323    active
324}
325
326pub fn parse_run_text_tokens(run_xml: &str) -> Vec<ParsedRunTextToken> {
327    let normalized_run_xml = prefer_alternate_content_choice(run_xml);
328    parse_active_run_text_tokens(&normalized_run_xml)
329}
330
331fn parse_active_run_text_tokens(normalized_run_xml: &str) -> Vec<ParsedRunTextToken> {
332    if let Some(tokens) = indexed_run_text_tokens(normalized_run_xml) {
333        return tokens;
334    }
335    if let Some(tokens) = stream_run_text_tokens(normalized_run_xml) {
336        return tokens;
337    }
338    let token_pattern = re::get_unchecked(
339        r#"(?is)<w:t\b(?:[^>]*[^/>])?>([\s\S]*?)</w:t>|<a:t\b(?:[^>]*[^/>])?>([\s\S]*?)</a:t>|<w:footnoteReference\b[^>]*w:id="(-?\d+)"[^>]*/>|<w:endnoteReference\b[^>]*w:id="(-?\d+)"[^>]*/>|<w:tab\b[^>]*/>|<a:tab\b[^>]*/>|<w:br\b[^>]*/>|<w:cr\b[^>]*/>|<a:br\b[^>]*/>|<w:noBreakHyphen\b[^>]*/>|<w:softHyphen\b[^>]*/>|<w:sym\b[^>]*/>|</w:p>"#,
340    );
341    let mut parts = Vec::new();
342    for caps in token_pattern.captures_iter(normalized_run_xml) {
343        let source_start = caps.get(0).unwrap().start();
344        if let Some(m) = caps.get(1) {
345            // Literal XML line endings are text whitespace; explicit break
346            // elements carry the document's line-break semantics.
347            let text = if m.as_str().contains(['\r', '\n']) {
348                decode_xml_entities(&m.as_str().replace("\r\n", " ").replace(['\r', '\n'], " "))
349            } else {
350                decode_xml_entities(m.as_str())
351            };
352            parts.push(ParsedRunTextToken {
353                source_start,
354                text,
355                note_reference: None,
356            });
357            continue;
358        }
359        if let Some(m) = caps.get(2) {
360            parts.push(ParsedRunTextToken {
361                source_start,
362                text: decode_xml_entities(m.as_str()),
363                note_reference: None,
364            });
365            continue;
366        }
367        if let Some(m) = caps.get(3) {
368            let reference_id: i64 = m.as_str().parse().unwrap_or(0);
369            if reference_id >= 0 {
370                parts.push(ParsedRunTextToken {
371                    source_start,
372                    text: String::new(),
373                    note_reference: Some(NoteReference {
374                        kind: NoteReferenceKind::Footnote,
375                        id: reference_id,
376                    }),
377                });
378            }
379            continue;
380        }
381        if let Some(m) = caps.get(4) {
382            let reference_id: i64 = m.as_str().parse().unwrap_or(0);
383            if reference_id >= 0 {
384                parts.push(ParsedRunTextToken {
385                    source_start,
386                    text: String::new(),
387                    note_reference: Some(NoteReference {
388                        kind: NoteReferenceKind::Endnote,
389                        id: reference_id,
390                    }),
391                });
392            }
393            continue;
394        }
395        let marker = caps
396            .get(0)
397            .map(|m| m.as_str().to_ascii_lowercase())
398            .unwrap_or_default();
399        if marker.starts_with("<w:nobreakhyphen")
400            || marker.starts_with("<w:softhyphen")
401            || marker.starts_with("<w:sym")
402        {
403            let raw = caps.get(0).unwrap().as_str();
404            let text = if marker.starts_with("<w:nobreakhyphen") {
405                "\u{2011}".to_string()
406            } else if marker.starts_with("<w:softhyphen") {
407                "\u{00ad}".to_string()
408            } else {
409                let code =
410                    get_attribute(raw, "w:char").and_then(|v| u32::from_str_radix(&v, 16).ok());
411                let font = get_attribute(raw, "w:font").unwrap_or_default();
412                code.and_then(|code| {
413                    super::symbols::symbol_character(&font, code).or_else(|| char::from_u32(code))
414                })
415                .unwrap_or('\u{fffd}')
416                .to_string()
417            };
418            parts.push(ParsedRunTextToken {
419                text,
420                source_start,
421                note_reference: None,
422            });
423        } else if marker == "</w:p>" {
424            parts.push(ParsedRunTextToken {
425                source_start,
426                text: "\n".to_string(),
427                note_reference: None,
428            });
429        } else if marker.starts_with("<w:tab") || marker.starts_with("<a:tab") {
430            parts.push(ParsedRunTextToken {
431                source_start,
432                text: "\t".to_string(),
433                note_reference: None,
434            });
435        } else {
436            parts.push(ParsedRunTextToken {
437                source_start,
438                text: "\n".to_string(),
439                note_reference: None,
440            });
441        }
442    }
443    parts
444}
445
446fn indexed_run_text_tokens(xml: &str) -> Option<Vec<ParsedRunTextToken>> {
447    let bytes = xml.as_bytes();
448    if memchr::memchr2_iter(b'?', b'!', bytes)
449        .any(|position| position != 0 && bytes[position - 1] == b'<')
450    {
451        return None;
452    }
453    let mut tokens = Vec::new();
454    let complete = crate::xml_index::for_each_element(xml, |element| {
455        let text = match element.name {
456            "w:r" | "w:rPr" => return true,
457            "w:t" | "a:t" => {
458                let Some(text) = element.content() else {
459                    return true;
460                };
461                if memchr::memchr(b'<', text.as_bytes()).is_some() {
462                    return false;
463                }
464                if element.name == "w:t" && memchr::memchr2(b'\r', b'\n', text.as_bytes()).is_some()
465                {
466                    decode_xml_entities(&text.replace("\r\n", " ").replace(['\r', '\n'], " "))
467                } else {
468                    decode_xml_entities(text)
469                }
470            }
471            "w:tab" | "a:tab" if element.content().is_none() => "\t".into(),
472            "w:br" | "w:cr" | "a:br" if element.content().is_none() => "\n".into(),
473            "w:noBreakHyphen" if element.content().is_none() => "\u{2011}".into(),
474            "w:softHyphen" if element.content().is_none() => "\u{00ad}".into(),
475            name => {
476                return ![
477                    "w:t",
478                    "a:t",
479                    "w:p",
480                    "w:footnoteReference",
481                    "w:endnoteReference",
482                    "w:tab",
483                    "a:tab",
484                    "w:br",
485                    "w:cr",
486                    "a:br",
487                    "w:noBreakHyphen",
488                    "w:softHyphen",
489                    "w:sym",
490                ]
491                .iter()
492                .any(|expected| name.eq_ignore_ascii_case(expected));
493            }
494        };
495        tokens.push(ParsedRunTextToken {
496            text,
497            source_start: element.source_start,
498            note_reference: None,
499        });
500        true
501    })?;
502    complete.then_some(tokens)
503}
504
505fn stream_run_text_tokens(xml: &str) -> Option<Vec<ParsedRunTextToken>> {
506    use quick_xml::events::Event;
507    if xml.contains("<!") {
508        return None;
509    }
510    let mut reader = quick_xml::Reader::from_str(xml);
511    let mut tokens = Vec::new();
512    loop {
513        let source_start = reader.buffer_position() as usize;
514        let event = reader.read_event().ok()?;
515        if let Event::Start(tag) | Event::Empty(tag) = &event {
516            if tag.as_ref().contains('<') {
517                return None;
518            }
519        }
520        match event {
521            Event::Start(tag) if matches!(tag.name().as_ref(), "w:t" | "a:t") => {
522                let word_text = tag.name().as_ref() == "w:t";
523                let text = reader.read_text(tag.name()).ok()?;
524                let text = text.as_ref();
525                if text.contains('<') {
526                    return None;
527                }
528                let text = if word_text && text.contains(['\r', '\n']) {
529                    decode_xml_entities(&text.replace("\r\n", " ").replace(['\r', '\n'], " "))
530                } else {
531                    decode_xml_entities(text)
532                };
533                tokens.push(ParsedRunTextToken {
534                    text,
535                    source_start,
536                    note_reference: None,
537                });
538            }
539            Event::Empty(tag) => {
540                let name = tag.name();
541                let name = name.as_ref();
542                let text = match name {
543                    "w:footnoteReference" | "w:endnoteReference" => {
544                        let raw = tag.as_ref();
545                        let mut ids = 0;
546                        for attribute in tag.attributes() {
547                            let attribute = attribute.ok()?;
548                            ids += usize::from(attribute.key.as_ref().eq_ignore_ascii_case("w:id"));
549                        }
550                        if ids != 1 {
551                            return None;
552                        }
553                        let Some(id) = raw
554                            .split_once("w:id=\"")
555                            .and_then(|(_, value)| value.split_once('"').map(|v| v.0))
556                        else {
557                            return None;
558                        };
559                        let digits = id.strip_prefix('-').unwrap_or(id);
560                        if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
561                            continue;
562                        }
563                        let id = id.parse::<i64>().unwrap_or(0);
564                        if id >= 0 {
565                            tokens.push(ParsedRunTextToken {
566                                text: String::new(),
567                                source_start,
568                                note_reference: Some(NoteReference {
569                                    kind: if name == "w:footnoteReference" {
570                                        NoteReferenceKind::Footnote
571                                    } else {
572                                        NoteReferenceKind::Endnote
573                                    },
574                                    id,
575                                }),
576                            });
577                        }
578                        continue;
579                    }
580                    "w:tab" | "a:tab" => "\t".to_owned(),
581                    "w:br" | "w:cr" | "a:br" => "\n".to_owned(),
582                    "w:noBreakHyphen" => "\u{2011}".to_owned(),
583                    "w:softHyphen" => "\u{00ad}".to_owned(),
584                    "w:sym" => {
585                        let code = get_attribute(tag.as_ref(), "w:char")
586                            .and_then(|v| u32::from_str_radix(&v, 16).ok());
587                        let font = get_attribute(tag.as_ref(), "w:font").unwrap_or_default();
588                        code.and_then(|code| {
589                            super::symbols::symbol_character(&font, code)
590                                .or_else(|| char::from_u32(code))
591                        })
592                        .unwrap_or('\u{fffd}')
593                        .to_string()
594                    }
595                    _ => {
596                        if [
597                            "w:footnoteReference",
598                            "w:endnoteReference",
599                            "w:tab",
600                            "a:tab",
601                            "w:br",
602                            "w:cr",
603                            "a:br",
604                            "w:noBreakHyphen",
605                            "w:softHyphen",
606                            "w:sym",
607                        ]
608                        .iter()
609                        .any(|expected| name.eq_ignore_ascii_case(expected))
610                        {
611                            return None;
612                        }
613                        continue;
614                    }
615                };
616                tokens.push(ParsedRunTextToken {
617                    text,
618                    source_start,
619                    note_reference: None,
620                });
621            }
622            Event::End(tag) if tag.name().as_ref().eq_ignore_ascii_case("w:p") => {
623                if !xml[source_start..reader.buffer_position() as usize]
624                    .eq_ignore_ascii_case("</w:p>")
625                {
626                    return None;
627                }
628                tokens.push(ParsedRunTextToken {
629                    text: "\n".into(),
630                    source_start,
631                    note_reference: None,
632                });
633            }
634            Event::Start(tag)
635                if ["w:t", "a:t"]
636                    .iter()
637                    .any(|name| tag.name().as_ref().eq_ignore_ascii_case(name)) =>
638            {
639                return None
640            }
641            Event::Eof => return Some(tokens),
642            _ => {}
643        }
644    }
645}
646
647pub fn parse_run_text(run_xml: &str) -> String {
648    parse_run_text_tokens(run_xml)
649        .into_iter()
650        .map(|token| token.text)
651        .collect()
652}
653
654fn hyperlink_href_from_tag(
655    hyperlink_tag: &str,
656    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
657) -> Option<String> {
658    let relationship_id = get_attribute(hyperlink_tag, "r:id");
659    let anchor = get_attribute_ref(hyperlink_tag, "w:anchor").map(decode_xml_entities);
660    let relationship_target = relationship_id
661        .as_deref()
662        .and_then(|id| context.relationships.get(id).cloned());
663    if relationship_id.is_some() && relationship_target.is_none() {
664        context.push_warning(format!(
665            "Missing hyperlink relationship target for {}",
666            relationship_id.unwrap_or_default()
667        ));
668    }
669    if let (Some(anchor), Some(target)) = (anchor.as_deref(), relationship_target.as_deref()) {
670        return Some(format!(
671            "{}#{anchor}",
672            target.split('#').next().unwrap_or(target)
673        ));
674    }
675    if let Some(target) = relationship_target {
676        return Some(target);
677    }
678    anchor.map(|a| format!("#{a}"))
679}
680
681fn hyperlink_href_from_field_instruction(raw_instruction: &str) -> Option<String> {
682    if raw_instruction.is_empty() {
683        return None;
684    }
685    let instruction = decode_xml_entities(raw_instruction);
686    if re::get_unchecked(r"(?i)^\s*(?:REF|PAGEREF|NOTEREF)\b").is_match(&instruction) {
687        if !re::get_unchecked(r"(?i)\s\\h(?:\s|$)").is_match(&instruction) {
688            return None;
689        }
690        return re::get_unchecked(r#"(?i)^\s*(?:REF|PAGEREF|NOTEREF)\s+(?:"([^"]+)"|([^\s\\]+))"#)
691            .captures(&instruction)
692            .and_then(|captures| captures.get(1).or_else(|| captures.get(2)))
693            .map(|anchor| format!("#{}", anchor.as_str()));
694    }
695    if !re::get_unchecked(r"(?i)^\s*HYPERLINK\b").is_match(&instruction) {
696        return None;
697    }
698    let anchor_match = re::get(r#"(?i)\\l\s+(?:"([^"]+)"|([^\s\\]+))"#)
699        .and_then(|re| re.captures(&instruction))
700        .and_then(|c| {
701            c.get(1)
702                .or_else(|| c.get(2))
703                .map(|m| m.as_str().to_string())
704        });
705    let explicit_target_match = re::get(r#"(?i)^\s*HYPERLINK\b\s+(?:"([^"]+)"|([^\s\\]+))"#)
706        .and_then(|re| re.captures(&instruction))
707        .and_then(|c| {
708            c.get(1)
709                .or_else(|| c.get(2))
710                .map(|m| m.as_str().to_string())
711        });
712    if let Some(target) = explicit_target_match {
713        if let Some(ref anchor) = anchor_match {
714            return Some(format!(
715                "{}#{anchor}",
716                target.split('#').next().unwrap_or(&target)
717            ));
718        }
719        return Some(target);
720    }
721    anchor_match.map(|a| format!("#{a}"))
722}
723
724pub fn parse_paragraph_runs(
725    paragraph_xml: &str,
726    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
727) -> Vec<ParagraphRunToken> {
728    paragraph_runs(paragraph_xml, context)
729        .into_iter()
730        .map(|run| ParagraphRunToken {
731            xml: run.xml.into_owned(),
732            start: run.start,
733            end: run.end,
734            link: run.link,
735            text_links: run.text_links,
736        })
737        .collect()
738}
739
740fn paragraph_nested_tag_ranges(paragraph_xml: &str, tag_name: &str) -> Vec<crate::xml::TagRange> {
741    let mut ranges = extract_balanced_tag_ranges(paragraph_xml, tag_name);
742    let mut cursor = 0usize;
743    while cursor < ranges.len() {
744        let range = &ranges[cursor];
745        let content_start =
746            crate::xml::tag_token_end(paragraph_xml, range.start).unwrap_or(range.end);
747        if content_start < range.end {
748            let content = &paragraph_xml[content_start..range.end];
749            if super::scan::contains_tag(content, tag_name) {
750                let nested = extract_balanced_tag_ranges(content, tag_name);
751                ranges.extend(nested.into_iter().map(|range| crate::xml::TagRange {
752                    start: content_start + range.start,
753                    end: content_start + range.end,
754                }));
755            }
756        }
757        cursor += 1;
758    }
759    ranges.sort_unstable_by_key(|range| range.start);
760    ranges
761}
762
763fn is_page_field_instruction(instruction: &str) -> bool {
764    matches!(
765        instruction
766            .split_whitespace()
767            .next()
768            .map(str::to_ascii_uppercase)
769            .as_deref(),
770        Some("PAGE" | "NUMPAGES")
771    )
772}
773
774fn paragraph_runs<'a>(
775    paragraph_xml: &'a str,
776    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
777) -> Vec<RunToken<'a>> {
778    let mut run_ranges = extract_balanced_tag_ranges(paragraph_xml, "w:r");
779    if super::scan::contains_tag(paragraph_xml, "w:del")
780        || super::scan::contains_tag(paragraph_xml, "w:moveFrom")
781    {
782        let mut deleted = extract_balanced_tag_ranges(paragraph_xml, "w:del");
783        deleted.extend(extract_balanced_tag_ranges(paragraph_xml, "w:moveFrom"));
784        run_ranges.retain(|run| {
785            !deleted.iter().any(|range| run.start >= range.start && run.end <= range.end)
786        });
787    }
788    let simple_fields = if super::scan::contains_tag(paragraph_xml, "w:fldSimple") {
789        let excluded_field_ranges: Vec<_> = [
790            "w:txbxContent",
791            "w:del",
792            "w:moveFrom",
793            "mc:AlternateContent",
794        ]
795        .iter()
796        .flat_map(|tag| extract_balanced_tag_ranges(paragraph_xml, tag))
797        .collect();
798        paragraph_nested_tag_ranges(paragraph_xml, "w:fldSimple")
799            .into_iter()
800            .filter(|field| {
801                !excluded_field_ranges
802                    .iter()
803                    .any(|excluded| field.start >= excluded.start && field.end <= excluded.end)
804            })
805            .collect::<Vec<_>>()
806    } else {
807        Vec::new()
808    };
809    if run_ranges.is_empty() && simple_fields.is_empty() {
810        return Vec::new();
811    }
812
813    let has_complex_fields = super::scan::contains_tag(paragraph_xml, "w:fldChar");
814    if !has_complex_fields
815        && !super::scan::contains_tag(paragraph_xml, "w:hyperlink")
816        && !super::scan::contains_tag(paragraph_xml, "w:fldSimple")
817    {
818        return run_ranges
819            .into_iter()
820            .map(|range| RunToken {
821                xml: std::borrow::Cow::Borrowed(&paragraph_xml[range.start..range.end]),
822                start: range.start,
823                end: range.end,
824                link: None,
825                text_links: Vec::new(),
826                empty_fields: Vec::new(),
827            })
828            .collect();
829    }
830
831    let mut hyperlink_ranges: Vec<_> = paragraph_nested_tag_ranges(paragraph_xml, "w:hyperlink")
832        .into_iter()
833        .map(|range| {
834            let hyperlink_xml = &paragraph_xml[range.start..range.end];
835            let hyperlink_tag = re::get(r"(?i)<w:hyperlink\b[^>]*>")
836                .and_then(|re| re.find(hyperlink_xml))
837                .map(|m| m.as_str().to_string())
838                .unwrap_or_default();
839            let href = hyperlink_href_from_tag(&hyperlink_tag, context);
840            (range, href)
841        })
842        .collect();
843    hyperlink_ranges.extend(
844        paragraph_nested_tag_ranges(paragraph_xml, "w:fldSimple")
845            .into_iter()
846            .filter_map(|range| {
847                let tag = super::scan::find_tag_token_ref(
848                    &paragraph_xml[range.start..range.end],
849                    "w:fldSimple",
850                )
851                .unwrap_or("");
852                let href = get_attribute(tag, "w:instr")
853                    .and_then(|instruction| hyperlink_href_from_field_instruction(&instruction))?;
854                Some((range, Some(href)))
855            }),
856    );
857    hyperlink_ranges.sort_by_key(|(range, _)| range.start);
858
859    let mut field_links_by_run: std::collections::HashMap<usize, String> =
860        std::collections::HashMap::new();
861    let mut field_text_links_by_run: std::collections::HashMap<
862        usize,
863        Vec<(usize, Option<String>)>,
864    > = std::collections::HashMap::new();
865    #[derive(Default)]
866    struct FieldState {
867        instruction: String,
868        link: Option<String>,
869        separated: bool,
870        start: usize,
871        instruction_position: Option<(usize, usize)>,
872        result_position: Option<(usize, usize)>,
873        empty_result_position: Option<(usize, usize)>,
874        has_result: bool,
875        has_unseparated_content: bool,
876        nested: bool,
877    }
878    let mut fields: Vec<FieldState> = Vec::new();
879    let mut complex_field_ranges = Vec::new();
880    let mut empty_fields_by_run: std::collections::HashMap<usize, Vec<(usize, String)>> =
881        std::collections::HashMap::new();
882    let field_tokens = re::get_unchecked(
883        r#"(?is)<w:instrText\b(?:[^>]*[^/>])?>([\s\S]*?)</w:instrText>|<w:fldChar\b[^>]*>|<w:t\b(?:[^>]*[^/>])?>[\s\S]*?</w:t>|<a:t\b(?:[^>]*[^/>])?>[\s\S]*?</a:t>|<(?:w|a):t\b[^>]*/>|<w:(?:drawing|pict)\b[^>]*>|<w:(?:sym|tab|br|cr|noBreakHyphen|softHyphen|footnoteReference|endnoteReference)\b[^>]*/>|<a:(?:tab|br)\b[^>]*/>"#,
884    );
885
886    for (run_index, range) in run_ranges.iter().enumerate() {
887        if !has_complex_fields
888            || (fields.is_empty()
889                && !paragraph_xml[range.start..range.end].contains("<mc:AlternateContent")
890                && crate::xml::find_ignore_ascii_case(
891                    &paragraph_xml[range.start..range.end],
892                    0,
893                    "<w:fldChar",
894                )
895                .is_none())
896        {
897            field_text_links_by_run.insert(run_index, vec![(0, None)]);
898            continue;
899        }
900        let run_xml = run_text_xml(&paragraph_xml[range.start..range.end]);
901        let mut text_links = vec![(0, fields.iter().rev().find_map(|field| field.link.clone()))];
902        for token in field_tokens.captures_iter(&run_xml) {
903            if let Some(instruction) = token.get(1) {
904                if let Some(field) = fields.last_mut().filter(|field| !field.separated) {
905                    field.instruction.push_str(instruction.as_str());
906                    field
907                        .instruction_position
908                        .get_or_insert((run_index, token.get(0).unwrap().end()));
909                }
910                continue;
911            }
912            let tag = token.get(0).unwrap().as_str();
913            if tag.to_ascii_lowercase().starts_with("<w:fldchar") {
914                match get_attribute(tag, "w:fldCharType").as_deref() {
915                    Some("begin") => {
916                        let nested = !fields.is_empty();
917                        for field in &mut fields {
918                            field.nested = true;
919                        }
920                        fields.push(FieldState {
921                            start: range.start + token.get(0).unwrap().start(),
922                            nested,
923                            ..FieldState::default()
924                        });
925                    }
926                    Some("separate") => {
927                        if let Some(field) = fields.last_mut() {
928                            if field.separated {
929                                field.nested = true;
930                            }
931                            field.link = hyperlink_href_from_field_instruction(&field.instruction);
932                            field.separated = true;
933                            field.result_position = Some((run_index, token.get(0).unwrap().end()));
934                        }
935                    }
936                    Some("end") => {
937                        if let Some(field) = fields.pop() {
938                            let end = range.start + token.get(0).unwrap().end();
939                            complex_field_ranges.push((field.start, end));
940                            let overlaps_simple = simple_fields
941                                .iter()
942                                .any(|simple| simple.start < end && simple.end > field.start);
943                            let has_other_content =
944                                paragraph_xml.get(field.start..end).is_none_or(|xml| {
945                                    ["w:sdt", "m:oMath", "w:object"]
946                                        .iter()
947                                        .any(|tag| super::scan::contains_tag(xml, tag))
948                                });
949                            if !field.nested
950                                && !overlaps_simple
951                                && !field.has_result
952                                && !has_other_content
953                            {
954                                let instruction = decode_xml_entities(&field.instruction);
955                                if is_page_field_instruction(&instruction) {
956                                    let result_position = field
957                                        .empty_result_position
958                                        .or(field.result_position)
959                                        .or_else(|| {
960                                            if field.separated || field.has_unseparated_content {
961                                                None
962                                            } else {
963                                                field.instruction_position
964                                            }
965                                        });
966                                    if let Some((result_run, result_offset)) = result_position {
967                                        empty_fields_by_run
968                                            .entry(result_run)
969                                            .or_default()
970                                            .push((result_offset, instruction));
971                                    }
972                                }
973                            }
974                        }
975                    }
976                    _ => {}
977                }
978                text_links.push((
979                    token.get(0).unwrap().end(),
980                    fields.iter().rev().find_map(|field| field.link.clone()),
981                ));
982            } else {
983                let is_text = tag.starts_with("<w:t") || tag.starts_with("<a:t");
984                let has_result = !is_text
985                    || tag
986                        .find('>')
987                        .zip(tag.rfind('<'))
988                        .is_some_and(|(start, end)| end > start + 1);
989                if has_result {
990                    for field in &mut fields {
991                        if field.separated {
992                            field.has_result = true;
993                        } else {
994                            field.has_unseparated_content = true;
995                        }
996                    }
997                } else if let Some(field) = fields.last_mut().filter(|field| field.separated) {
998                    field
999                        .empty_result_position
1000                        .get_or_insert((run_index, token.get(0).unwrap().start()));
1001                }
1002                let link = fields.iter().rev().find_map(|field| field.link.clone());
1003                if let Some(link) = link.as_ref() {
1004                    field_links_by_run
1005                        .entry(run_index)
1006                        .or_insert_with(|| link.clone());
1007                }
1008            }
1009        }
1010        field_text_links_by_run.insert(run_index, text_links);
1011    }
1012
1013    complex_field_ranges.extend(
1014        fields
1015            .iter()
1016            .map(|field| (field.start, paragraph_xml.len())),
1017    );
1018    let mut synthetic_fields = Vec::new();
1019    for simple in &simple_fields {
1020        if simple_fields.iter().any(|other| {
1021            other.start != simple.start && other.start < simple.end && other.end > simple.start
1022        }) || complex_field_ranges
1023            .iter()
1024            .any(|(start, end)| *start < simple.end && *end > simple.start)
1025        {
1026            continue;
1027        }
1028        let xml = &paragraph_xml[simple.start..simple.end];
1029        let Some(instruction) = super::scan::find_tag_token_ref(xml, "w:fldSimple")
1030            .and_then(|tag| get_attribute(tag, "w:instr"))
1031            .map(|instruction| decode_xml_entities(&instruction))
1032            .filter(|instruction| is_page_field_instruction(instruction))
1033        else {
1034            continue;
1035        };
1036        if parse_run_text_tokens(xml)
1037            .iter()
1038            .any(|token| !token.text.is_empty() || token.note_reference.is_some())
1039            || ["w:drawing", "w:pict", "w:object", "w:sdt", "m:oMath"]
1040                .iter()
1041                .any(|tag| super::scan::contains_tag(xml, tag))
1042        {
1043            continue;
1044        }
1045        if let Some((run_index, _)) = run_ranges
1046            .iter()
1047            .enumerate()
1048            .find(|(_, run)| run.start >= simple.start && run.end <= simple.end)
1049        {
1050            empty_fields_by_run
1051                .entry(run_index)
1052                .or_default()
1053                .push((0, instruction));
1054        } else {
1055            synthetic_fields.push(RunToken {
1056                xml: std::borrow::Cow::Borrowed(xml),
1057                start: simple.start,
1058                end: simple.end,
1059                link: None,
1060                text_links: Vec::new(),
1061                empty_fields: vec![(0, instruction)],
1062            });
1063        }
1064    }
1065
1066    let mut hyperlink_range_cursor = 0usize;
1067    let mut active_hyperlinks: Vec<&(crate::xml::TagRange, Option<String>)> = Vec::new();
1068    let mut runs: Vec<_> = run_ranges
1069        .into_iter()
1070        .enumerate()
1071        .map(|(run_index, range)| {
1072            active_hyperlinks.retain(|(hyperlink, _)| hyperlink.end > range.start);
1073            while hyperlink_range_cursor < hyperlink_ranges.len()
1074                && hyperlink_ranges[hyperlink_range_cursor].0.start < range.end
1075            {
1076                let hyperlink = &hyperlink_ranges[hyperlink_range_cursor];
1077                if hyperlink.0.end > range.start {
1078                    active_hyperlinks.push(hyperlink);
1079                }
1080                hyperlink_range_cursor += 1;
1081            }
1082            let field_links = field_text_links_by_run
1083                .remove(&run_index)
1084                .unwrap_or_default();
1085            let overlapping = &active_hyperlinks;
1086            let hyperlink_href = overlapping
1087                .iter()
1088                .rev()
1089                .find(|(hyperlink_range, _)| {
1090                    range.start >= hyperlink_range.start && range.end <= hyperlink_range.end
1091                })
1092                .and_then(|(_, href)| href.clone());
1093            let mut offsets: Vec<_> = field_links.iter().map(|(offset, _)| *offset).collect();
1094            for (hyperlink, _) in overlapping {
1095                offsets.push(hyperlink.start.saturating_sub(range.start));
1096                if hyperlink.end < range.end {
1097                    offsets.push(hyperlink.end - range.start);
1098                }
1099            }
1100            offsets.sort_unstable();
1101            offsets.dedup();
1102            let text_links = offsets
1103                .into_iter()
1104                .map(|offset| {
1105                    let position = range.start + offset;
1106                    let href = overlapping
1107                        .iter()
1108                        .rev()
1109                        .find(|(hyperlink, _)| {
1110                            position >= hyperlink.start && position < hyperlink.end
1111                        })
1112                        .and_then(|(_, href)| href.clone())
1113                        .or_else(|| {
1114                            field_links
1115                                .iter()
1116                                .rev()
1117                                .find(|(start, _)| *start <= offset)
1118                                .and_then(|(_, href)| href.clone())
1119                        });
1120                    (offset, href)
1121                })
1122                .collect();
1123            let link = hyperlink_href.or_else(|| field_links_by_run.get(&run_index).cloned());
1124            RunToken {
1125                xml: std::borrow::Cow::Borrowed(&paragraph_xml[range.start..range.end]),
1126                start: range.start,
1127                end: range.end,
1128                link,
1129                text_links,
1130                empty_fields: empty_fields_by_run.remove(&run_index).unwrap_or_default(),
1131            }
1132        })
1133        .collect();
1134    runs.extend(synthetic_fields);
1135    runs.sort_by_key(|run| run.start);
1136    runs
1137}
1138
1139#[derive(Clone, Debug)]
1140pub struct ParagraphFormFieldToken {
1141    pub start: usize,
1142    pub end: usize,
1143    pub field: FormFieldRunNode,
1144}
1145
1146fn decode_active_x_binary_markup(binary: &[u8]) -> String {
1147    let utf16: String = binary
1148        .chunks_exact(2)
1149        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
1150        .filter_map(|code_unit| char::from_u32(code_unit as u32))
1151        .collect();
1152    if re::get(r"(?i)input\s+type=").is_some_and(|re| re.is_match(&utf16)) {
1153        return utf16;
1154    }
1155    binary.iter().map(|&b| b as char).collect()
1156}
1157
1158pub fn parse_run_active_x_checkbox_field(
1159    run_xml: &str,
1160    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1161    style: Option<TextStyle>,
1162    link: Option<String>,
1163) -> Option<FormFieldRunNode> {
1164    parse_run_active_x_checkbox_field_shared(run_xml, context, style.map(Into::into), link)
1165}
1166
1167fn parse_active_x_source_preview(
1168    run_xml: &str,
1169    control_start: usize,
1170    control_tag: &str,
1171    relationship_id: &str,
1172    checked: bool,
1173    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1174) -> Option<Box<FormFieldSourcePreview>> {
1175    let shape_id = decode_xml_entities(&get_attribute(control_tag, "w:shapeid")?);
1176    if shape_id.is_empty() {
1177        return None;
1178    }
1179    let object = extract_balanced_tag_ranges(run_xml, "w:object")
1180        .into_iter()
1181        .find(|range| range.start < control_start && control_start < range.end)?;
1182    let object_xml = &run_xml[object.start..object.end];
1183    let mut matches = extract_direct_child_tag_ranges(object_xml)
1184        .into_iter()
1185        .filter(|range| range.tag_name.eq_ignore_ascii_case("v:shape"))
1186        .filter(|range| {
1187            let shape_xml = &object_xml[range.start..range.end];
1188            let Some(tag_end) = crate::xml::tag_token_end(shape_xml, 1) else {
1189                return false;
1190            };
1191            get_attribute(&shape_xml[..tag_end], "id")
1192                .is_some_and(|id| decode_xml_entities(&id) == shape_id)
1193        });
1194    let matched = matches.next()?;
1195    if matches.next().is_some() {
1196        return None;
1197    }
1198    let shape_xml = &object_xml[matched.start..matched.end];
1199    let children = extract_direct_child_tag_ranges(shape_xml);
1200    let mut image_data = children
1201        .iter()
1202        .filter(|range| range.tag_name.eq_ignore_ascii_case("v:imagedata"));
1203    let data_range = image_data.next()?;
1204    if image_data.next().is_some()
1205        || children.iter().any(|range| {
1206            range.tag_name.eq_ignore_ascii_case("v:group")
1207                || range.tag_name.eq_ignore_ascii_case("v:shape")
1208                || range.tag_name.eq_ignore_ascii_case("v:textbox")
1209        })
1210    {
1211        return None;
1212    }
1213    let image_relationship =
1214        get_attribute(&shape_xml[data_range.start..data_range.end], "r:id")?;
1215    let part_name = context.relationships.get(&image_relationship)?;
1216    if context.binary_assets.get(part_name)?.is_empty() {
1217        return None;
1218    }
1219    let mut image = parse_run_image_block(shape_xml, context)?;
1220    if is_windows_metafile_content_type(image.content_type.as_deref(), image.part_name.as_deref())
1221        && !matches!(image.content_type.as_deref(), Some("image/svg+xml" | "image/png"))
1222    {
1223        let svg = crate::wmf::wmf_bitmap_preview_to_svg(image.data.as_deref()?)?;
1224        image.src = Some(svg_data_uri(&svg));
1225        image.content_type = Some("image/svg+xml".to_owned());
1226    }
1227    if image.part_name.as_deref() != Some(part_name.as_str())
1228        || image.data.as_ref().is_none_or(Vec::is_empty)
1229        || image.src.as_deref().is_none_or(str::is_empty)
1230        || image
1231            .content_type
1232            .as_deref()
1233            .is_none_or(|content_type| !content_type.starts_with("image/"))
1234    {
1235        return None;
1236    }
1237    Some(Box::new(FormFieldSourcePreview {
1238        relationship_id: relationship_id.to_owned(),
1239        shape_id,
1240        checked,
1241        image: Box::new(image),
1242    }))
1243}
1244
1245fn parse_run_active_x_checkbox_field_shared(
1246    run_xml: &str,
1247    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1248    style: Option<SharedTextStyle>,
1249    link: Option<String>,
1250) -> Option<FormFieldRunNode> {
1251    if !re::get(r"(?i)<w:object\b").is_some_and(|re| re.is_match(run_xml))
1252        || !re::get(r"(?i)<w:control\b").is_some_and(|re| re.is_match(run_xml))
1253    {
1254        return None;
1255    }
1256    let control = re::get(r"(?i)<w:control\b[^>]*/?>")
1257        .and_then(|re| re.find(run_xml))?;
1258    let control_tag = control.as_str();
1259    let control_relationship_id = get_attribute(&control_tag, "r:id")?;
1260    let active_x_part_name = context.relationships.get(&control_relationship_id)?.clone();
1261    let active_x_xml = context.parts.get(&active_x_part_name)?.content.clone();
1262    let ocx_tag = re::get(r"(?i)<ax:ocx\b[^>]*/?>")
1263        .and_then(|re| re.find(&active_x_xml))
1264        .map(|m| m.as_str().to_string())
1265        .unwrap_or_default();
1266    let binary_relationship_id = get_attribute(&ocx_tag, "r:id")?;
1267    let active_x_relationships = parse_relationships_from_parts(context.parts, &active_x_part_name);
1268    let active_x_binary_part_name = active_x_relationships.get(&binary_relationship_id)?.clone();
1269    let active_x_binary = context.binary_assets.get(&active_x_binary_part_name)?;
1270    if active_x_binary.is_empty() {
1271        return None;
1272    }
1273    let markup = decode_active_x_binary_markup(active_x_binary);
1274    if !re::get(r#"(?i)input\s+type\s*=\s*"checkbox""#).is_some_and(|re| re.is_match(&markup)) {
1275        return None;
1276    }
1277    let name = re::get(r#"(?i)\bname\s*=\s*"([^"]+)""#)
1278        .and_then(|re| re.captures(&markup))
1279        .and_then(|c| c.get(1))
1280        .map(|m| m.as_str().trim().to_string());
1281    let checked = re::get(r"(?i)\bchecked\b").is_some_and(|re| re.is_match(&markup));
1282    let source_preview = parse_active_x_source_preview(
1283        run_xml,
1284        control.start(),
1285        control_tag,
1286        &control_relationship_id,
1287        checked,
1288        context,
1289    );
1290    Some(FormFieldRunNode {
1291        r#type: FormFieldRunNodeType::FormField,
1292        field_type: FormFieldType::Checkbox,
1293        source_kind: Some(FormFieldSourceKind::Legacy),
1294        id: None,
1295        tag: None,
1296        title: None,
1297        placeholder: None,
1298        checked: Some(checked),
1299        value: None,
1300        options: None,
1301        widget: Some(FormFieldWidgetSettings {
1302            name,
1303            enabled: None,
1304            calc_on_exit: None,
1305            text: None,
1306            checkbox: None,
1307            dropdown: None,
1308        }),
1309        checked_symbol: Some("☒".to_string()),
1310        unchecked_symbol: Some("☐".to_string()),
1311        style,
1312        link,
1313        source_xml: Some(run_xml.to_string()),
1314        source_preview,
1315    })
1316}
1317
1318fn parse_legacy_form_field_from_range(
1319    paragraph_xml: &str,
1320    runs: &[RunToken<'_>],
1321    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1322    paragraph_style_id: Option<&str>,
1323    table_run_style: Option<&TextStyle>,
1324    start_run_index: usize,
1325    separate_run_index: Option<usize>,
1326    end_run_index: usize,
1327    raw_instruction: &str,
1328    ff_data_xml: Option<&str>,
1329) -> Option<FormFieldRunNode> {
1330    let instruction = decode_xml_entities(raw_instruction)
1331        .split_whitespace()
1332        .collect::<Vec<_>>()
1333        .join(" ");
1334    let normalized_instruction = instruction.to_ascii_uppercase();
1335    let field_type = if normalized_instruction.contains("FORMCHECKBOX") {
1336        FormFieldType::Checkbox
1337    } else if normalized_instruction.contains("FORMDROPDOWN") {
1338        FormFieldType::Dropdown
1339    } else if normalized_instruction.contains("FORMTEXT") {
1340        FormFieldType::Text
1341    } else {
1342        return None;
1343    };
1344
1345    let result_start_run_index = separate_run_index
1346        .map(|i| i + 1)
1347        .unwrap_or((end_run_index).min(start_run_index + 1));
1348    let result_runs = if result_start_run_index < end_run_index {
1349        &runs[result_start_run_index..end_run_index]
1350    } else {
1351        &[]
1352    };
1353    let fallback_result_text = normalize_legacy_form_display_value(Some(
1354        &result_runs
1355            .iter()
1356            .map(|r| parse_run_text(&r.xml))
1357            .collect::<String>(),
1358    ));
1359    let style_run_xml = result_runs
1360        .iter()
1361        .find(|run| {
1362            !parse_run_text(&run.xml).trim().is_empty()
1363                || re::get(r"(?i)<w:sym\b").is_some_and(|re| re.is_match(&run.xml))
1364        })
1365        .or_else(|| runs.get(start_run_index))
1366        .map(|r| r.xml.as_ref());
1367    let style = style_run_xml.and_then(|xml| {
1368        parse_run_style_in_table(xml, context, paragraph_style_id, table_run_style)
1369    });
1370    let link = runs
1371        .get(start_run_index..=end_run_index.min(runs.len().saturating_sub(1)))
1372        .and_then(|slice| slice.iter().find_map(|r| r.link.clone()));
1373    let field_xml = runs
1374        .get(start_run_index)
1375        .zip(runs.get(end_run_index))
1376        .map(|(start, end)| paragraph_xml[start.start..end.end].to_string());
1377
1378    let name_tag = ff_data_xml.and_then(|xml| {
1379        re::get(r"(?i)<w:name\b[^>]*/?>")
1380            .and_then(|re| re.find(xml))
1381            .map(|m| m.as_str().to_string())
1382    });
1383    let enabled = ff_data_xml.and_then(|xml| parse_on_off_attribute(xml, "enabled"));
1384    let calc_on_exit = ff_data_xml.and_then(|xml| parse_on_off_attribute(xml, "calcOnExit"));
1385    let mut widget_settings = FormFieldWidgetSettings {
1386        name: name_tag
1387            .as_deref()
1388            .and_then(|tag| get_attribute(tag, "w:val"))
1389            .and_then(|value| decode_xml_attribute(Some(&value))),
1390        enabled,
1391        calc_on_exit,
1392        text: None,
1393        checkbox: None,
1394        dropdown: None,
1395    };
1396
1397    match field_type {
1398        FormFieldType::Checkbox => {
1399            let checkbox_xml = ff_data_xml
1400                .and_then(|xml| {
1401                    extract_balanced_tag_blocks(xml, "w:checkBox")
1402                        .into_iter()
1403                        .next()
1404                })
1405                .or_else(|| {
1406                    ff_data_xml.and_then(|xml| {
1407                        re::get(r"(?i)<w:checkBox\b[^>]*/?>")
1408                            .and_then(|re| re.find(xml))
1409                            .map(|m| m.as_str().to_string())
1410                    })
1411                });
1412            let default_tag = checkbox_xml.as_deref().and_then(|xml| {
1413                re::get(r"(?i)<w:default\b[^>]*/?>")
1414                    .and_then(|re| re.find(xml))
1415                    .map(|m| m.as_str().to_string())
1416            });
1417            let checked_tag = checkbox_xml.as_deref().and_then(|xml| {
1418                re::get(r"(?i)<w:checked\b[^>]*/?>")
1419                    .and_then(|re| re.find(xml))
1420                    .map(|m| m.as_str().to_string())
1421            });
1422            let size_auto_tag = checkbox_xml.as_deref().and_then(|xml| {
1423                re::get(r"(?i)<w:sizeAuto\b[^>]*/?>")
1424                    .and_then(|re| re.find(xml))
1425                    .map(|m| m.as_str().to_string())
1426            });
1427            let size_tag = checkbox_xml.as_deref().and_then(|xml| {
1428                re::get(r"(?i)<w:size\b[^>]*/?>")
1429                    .and_then(|re| re.find(xml))
1430                    .map(|m| m.as_str().to_string())
1431            });
1432            let default_checked = parse_on_off_tag_value(default_tag.as_deref(), &["w:val"]);
1433            let checked =
1434                parse_on_off_tag_value(checked_tag.as_deref(), &["w:val"]).or(default_checked);
1435            let size_value = size_tag
1436                .as_deref()
1437                .and_then(|t| parse_integer_attribute(t, "w:val"));
1438            let size_pt = size_value
1439                .filter(|v| *v > 0)
1440                .map(|v| ((v as f64) / 2.0 * 100.0).round() / 100.0);
1441            let size_mode = if size_auto_tag.is_some() {
1442                Some(FormFieldCheckboxSizeMode::Auto)
1443            } else if size_pt.is_some() {
1444                Some(FormFieldCheckboxSizeMode::Exact)
1445            } else {
1446                None
1447            };
1448            if default_checked.is_some() || size_mode.is_some() || size_pt.is_some() {
1449                widget_settings.checkbox = Some(FormFieldCheckboxWidgetSettings {
1450                    default_checked,
1451                    size_mode,
1452                    size_pt,
1453                });
1454            }
1455            return Some(FormFieldRunNode {
1456                r#type: FormFieldRunNodeType::FormField,
1457                field_type: FormFieldType::Checkbox,
1458                source_kind: Some(FormFieldSourceKind::Legacy),
1459                id: None,
1460                tag: None,
1461                title: None,
1462                placeholder: None,
1463                checked: Some(checked.unwrap_or(false)),
1464                value: None,
1465                options: None,
1466                widget: Some(widget_settings),
1467                checked_symbol: Some("☒".to_string()),
1468                unchecked_symbol: Some("☐".to_string()),
1469                style,
1470                link,
1471                source_xml: field_xml,
1472                source_preview: None,
1473            });
1474        }
1475        FormFieldType::Dropdown => {
1476            let dropdown_xml = ff_data_xml
1477                .and_then(|xml| {
1478                    extract_balanced_tag_blocks(xml, "w:ddList")
1479                        .into_iter()
1480                        .next()
1481                })
1482                .or_else(|| {
1483                    ff_data_xml.and_then(|xml| {
1484                        re::get(r"(?i)<w:ddList\b[^>]*/?>")
1485                            .and_then(|re| re.find(xml))
1486                            .map(|m| m.as_str().to_string())
1487                    })
1488                });
1489            let options: Vec<FormFieldOption> = dropdown_xml
1490                .as_deref()
1491                .map(|xml| {
1492                    re::get(r"(?i)<w:listEntry\b[^>]*/?>")
1493                        .map(|re| {
1494                            re.find_iter(xml)
1495                                .filter_map(|m| {
1496                                    let entry = get_attribute(m.as_str(), "w:val")
1497                                        .and_then(|value| decode_xml_attribute(Some(&value)))?
1498                                        .trim()
1499                                        .to_string();
1500                                    if entry.is_empty() {
1501                                        None
1502                                    } else {
1503                                        Some(FormFieldOption {
1504                                            display_text: entry.clone(),
1505                                            value: Some(entry),
1506                                        })
1507                                    }
1508                                })
1509                                .collect()
1510                        })
1511                        .unwrap_or_default()
1512                })
1513                .unwrap_or_default();
1514            let default_tag = dropdown_xml.as_deref().and_then(|xml| {
1515                re::get(r"(?i)<w:default\b[^>]*/?>")
1516                    .and_then(|re| re.find(xml))
1517                    .map(|m| m.as_str().to_string())
1518            });
1519            let default_value = default_tag
1520                .as_deref()
1521                .and_then(|tag| get_attribute(tag, "w:val"))
1522                .as_deref()
1523                .and_then(|value| decode_xml_attribute(Some(&value)))
1524                .map(|v| v.trim().to_string())
1525                .filter(|v| !v.is_empty());
1526            let dropdown_default_value = default_value.clone();
1527            if dropdown_default_value.is_some() {
1528                widget_settings.dropdown = Some(FormFieldDropdownWidgetSettings {
1529                    default_value: dropdown_default_value,
1530                });
1531            }
1532            let value = fallback_result_text
1533                .or(default_value)
1534                .or_else(|| options.first().map(|o| o.display_text.clone()));
1535            return Some(FormFieldRunNode {
1536                r#type: FormFieldRunNodeType::FormField,
1537                field_type: FormFieldType::Dropdown,
1538                source_kind: Some(FormFieldSourceKind::Legacy),
1539                id: None,
1540                tag: None,
1541                title: None,
1542                placeholder: None,
1543                checked: None,
1544                value,
1545                options: if options.is_empty() {
1546                    None
1547                } else {
1548                    Some(options)
1549                },
1550                widget: Some(widget_settings),
1551                checked_symbol: None,
1552                unchecked_symbol: None,
1553                style,
1554                link,
1555                source_xml: field_xml,
1556                source_preview: None,
1557            });
1558        }
1559        FormFieldType::Text => {
1560            let text_input_xml = ff_data_xml
1561                .and_then(|xml| {
1562                    extract_balanced_tag_blocks(xml, "w:textInput")
1563                        .into_iter()
1564                        .next()
1565                })
1566                .or_else(|| {
1567                    ff_data_xml.and_then(|xml| {
1568                        re::get(r"(?i)<w:textInput\b[^>]*/?>")
1569                            .and_then(|re| re.find(xml))
1570                            .map(|m| m.as_str().to_string())
1571                    })
1572                });
1573            let type_tag = text_input_xml.as_deref().and_then(|xml| {
1574                re::get(r"(?i)<w:type\b[^>]*/?>")
1575                    .and_then(|re| re.find(xml))
1576                    .map(|m| m.as_str().to_string())
1577            });
1578            let default_tag = text_input_xml.as_deref().and_then(|xml| {
1579                re::get(r"(?i)<w:default\b[^>]*/?>")
1580                    .and_then(|re| re.find(xml))
1581                    .map(|m| m.as_str().to_string())
1582            });
1583            let max_length_tag = text_input_xml.as_deref().and_then(|xml| {
1584                re::get(r"(?i)<w:maxLength\b[^>]*/?>")
1585                    .and_then(|re| re.find(xml))
1586                    .map(|m| m.as_str().to_string())
1587            });
1588            let format_tag = text_input_xml.as_deref().and_then(|xml| {
1589                re::get(r"(?i)<w:format\b[^>]*/?>")
1590                    .and_then(|re| re.find(xml))
1591                    .map(|m| m.as_str().to_string())
1592            });
1593            let input_type = type_tag
1594                .as_deref()
1595                .and_then(|tag| get_attribute(tag, "w:val"))
1596                .as_deref()
1597                .and_then(|value| decode_xml_attribute(Some(&value)));
1598            let default_text = default_tag
1599                .as_deref()
1600                .and_then(|tag| get_attribute(tag, "w:val"))
1601                .as_deref()
1602                .and_then(|value| decode_xml_attribute(Some(&value)));
1603            let max_length = max_length_tag
1604                .as_deref()
1605                .and_then(|tag| parse_integer_attribute(tag, "w:val"));
1606            let text_format = format_tag
1607                .as_deref()
1608                .and_then(|tag| get_attribute(tag, "w:val"))
1609                .as_deref()
1610                .and_then(|value| decode_xml_attribute(Some(&value)));
1611            let text_default = default_text.clone();
1612            if input_type.is_some()
1613                || text_default.is_some()
1614                || max_length.is_some()
1615                || text_format.is_some()
1616            {
1617                widget_settings.text = Some(FormFieldTextWidgetSettings {
1618                    input_type,
1619                    default_text: text_default,
1620                    max_length,
1621                    text_format,
1622                });
1623            }
1624            let value = fallback_result_text.or(default_text);
1625            return Some(FormFieldRunNode {
1626                r#type: FormFieldRunNodeType::FormField,
1627                field_type: FormFieldType::Text,
1628                source_kind: Some(FormFieldSourceKind::Legacy),
1629                id: None,
1630                tag: None,
1631                title: None,
1632                placeholder: None,
1633                checked: None,
1634                value,
1635                options: None,
1636                widget: Some(widget_settings),
1637                checked_symbol: None,
1638                unchecked_symbol: None,
1639                style,
1640                link,
1641                source_xml: field_xml,
1642                source_preview: None,
1643            });
1644        }
1645        FormFieldType::Date => None,
1646    }
1647}
1648
1649fn parse_legacy_paragraph_form_field_tokens(
1650    paragraph_xml: &str,
1651    runs: &[RunToken<'_>],
1652    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1653    paragraph_style_id: Option<&str>,
1654    table_run_style: Option<&TextStyle>,
1655) -> Vec<ParagraphFormFieldToken> {
1656    if runs.is_empty() {
1657        return Vec::new();
1658    }
1659    let mut tokens = Vec::new();
1660    let mut stack: Vec<(usize, Option<usize>, Vec<String>, Option<String>)> = Vec::new();
1661    let inline_token_pattern = re::get_unchecked(
1662        r#"(?is)<w:fldChar\b[^>]*\bw:fldCharType="(begin|separate|end)"[^>]*>([\s\S]*?)</w:fldChar>|<w:fldChar\b[^>]*\bw:fldCharType="(begin|separate|end)"[^>]*/>|<w:instrText\b(?:[^>]*[^/>])?>([\s\S]*?)</w:instrText>"#,
1663    );
1664
1665    for (run_index, run) in runs.iter().enumerate() {
1666        for caps in inline_token_pattern.captures_iter(&run.xml) {
1667            if let Some(chunk) = caps.get(4) {
1668                if let Some(current) = stack.last_mut() {
1669                    current.2.push(chunk.as_str().to_string());
1670                }
1671                continue;
1672            }
1673            let field_type = caps
1674                .get(1)
1675                .or_else(|| caps.get(3))
1676                .map(|m| m.as_str().to_ascii_lowercase())
1677                .unwrap_or_default();
1678            if field_type == "begin" {
1679                let field_xml = caps.get(0).map(|m| m.as_str()).unwrap_or_default();
1680                let ff_data_xml = extract_balanced_tag_blocks(field_xml, "w:ffData")
1681                    .into_iter()
1682                    .next()
1683                    .or_else(|| {
1684                        re::get(r"(?i)<w:ffData\b[^>]*/?>")
1685                            .and_then(|re| re.find(field_xml))
1686                            .map(|m| m.as_str().to_string())
1687                    });
1688                stack.push((run_index, None, Vec::new(), ff_data_xml));
1689                continue;
1690            }
1691            if field_type == "separate" {
1692                if let Some(current) = stack.last_mut() {
1693                    if current.1.is_none() {
1694                        current.1 = Some(run_index);
1695                    }
1696                }
1697                continue;
1698            }
1699            if field_type == "end" {
1700                let Some(current) = stack.pop() else { continue };
1701                let field = parse_legacy_form_field_from_range(
1702                    paragraph_xml,
1703                    runs,
1704                    context,
1705                    paragraph_style_id,
1706                    table_run_style,
1707                    current.0,
1708                    current.1,
1709                    run_index,
1710                    &current.2.join(" "),
1711                    current.3.as_deref(),
1712                );
1713                let Some(field) = field else { continue };
1714                let start = runs.get(current.0).map(|r| r.start);
1715                let end = runs.get(run_index).map(|r| r.end);
1716                if let (Some(start), Some(end)) = (start, end) {
1717                    if end > start {
1718                        tokens.push(ParagraphFormFieldToken { start, end, field });
1719                    }
1720                }
1721            }
1722        }
1723    }
1724    tokens
1725}
1726
1727fn trimmed_sdt_dropdown_style(
1728    runs: &[String],
1729    content_text: &str,
1730    fallback: Option<SharedTextStyle>,
1731    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1732    paragraph_style_id: Option<&str>,
1733    table_run_style: Option<&TextStyle>,
1734) -> Option<SharedTextStyle> {
1735    let Some(first_run) = runs.first() else {
1736        return fallback;
1737    };
1738    let first_text = parse_run_text(first_run);
1739    if !first_text.trim().is_empty() || content_text.trim().is_empty() {
1740        return fallback;
1741    }
1742
1743    let retained_start = content_text.len() - content_text.trim_start().len();
1744    let retained_end = content_text.trim_end().len();
1745    let mut cursor = 0;
1746    let mut retained_styles = Vec::new();
1747    for (index, run) in runs.iter().enumerate() {
1748        let text = if index == 0 {
1749            std::borrow::Cow::Borrowed(first_text.as_str())
1750        } else {
1751            std::borrow::Cow::Owned(parse_run_text(run))
1752        };
1753        if !content_text
1754            .get(cursor..)
1755            .is_some_and(|remaining| remaining.starts_with(text.as_ref()))
1756        {
1757            return fallback;
1758        }
1759        let end = cursor + text.len();
1760        if !text.is_empty() && cursor < retained_end && end > retained_start {
1761            retained_styles.push(parse_run_style_in_table(
1762                run,
1763                context,
1764                paragraph_style_id,
1765                table_run_style,
1766            ));
1767        }
1768        cursor = end;
1769    }
1770    if cursor != content_text.len() || retained_styles.is_empty() {
1771        return fallback;
1772    }
1773
1774    let Some(seed) = fallback
1775        .as_ref()
1776        .or_else(|| retained_styles.iter().flatten().next())
1777    else {
1778        return None;
1779    };
1780    let mut recovered = (**seed).clone();
1781    let mut changed = false;
1782    let mut has_any = false;
1783    macro_rules! recover_common_properties {
1784        ($($property:ident),+ $(,)?) => {
1785            $(
1786                let previous = fallback.as_ref().and_then(|style| style.$property.clone());
1787                let common = retained_styles[0].as_ref().and_then(|style| style.$property.as_ref());
1788                let value = if retained_styles.iter().skip(1).all(|style| {
1789                    style.as_ref().and_then(|style| style.$property.as_ref()) == common
1790                }) {
1791                    common.cloned()
1792                } else {
1793                    previous.clone()
1794                };
1795                changed |= value != previous;
1796                has_any |= value.is_some();
1797                recovered.$property = value;
1798            )+
1799        };
1800    }
1801    // A single field style can only adopt properties shared by all retained text.
1802    recover_common_properties!(
1803        bold,
1804        italic,
1805        underline,
1806        strike,
1807        all_caps,
1808        small_caps,
1809        color,
1810        highlight,
1811        background_color,
1812        font_size_pt,
1813        font_size_cs_pt,
1814        font_family,
1815        source_font_family,
1816        font_family_ascii,
1817        font_family_h_ansi,
1818        font_family_east_asia,
1819        font_family_cs,
1820        font_theme_ascii,
1821        font_theme_h_ansi,
1822        font_theme_east_asia,
1823        font_theme_cs,
1824        resolved_font_family_ascii,
1825        resolved_font_family_h_ansi,
1826        resolved_font_family_east_asia,
1827        resolved_font_family_cs,
1828        font_hint,
1829        language,
1830        language_east_asia,
1831        language_bidi,
1832        right_to_left,
1833        complex_script,
1834        character_spacing_twips,
1835        character_scale_percent,
1836        source_character_scale,
1837        kerning_min_font_size_pt,
1838        ligatures,
1839        contextual_alternates,
1840        vertical_align,
1841        run_border,
1842    );
1843    if !changed {
1844        fallback
1845    } else if has_any {
1846        Some(recovered.into())
1847    } else {
1848        None
1849    }
1850}
1851
1852fn parse_form_field_from_sdt_xml(
1853    sdt_xml: &str,
1854    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1855    paragraph_style_id: Option<&str>,
1856    table_run_style: Option<&TextStyle>,
1857    link: Option<String>,
1858) -> Option<FormFieldRunNode> {
1859    let sdt_properties_xml = extract_balanced_tag_blocks(sdt_xml, "w:sdtPr")
1860        .into_iter()
1861        .next()
1862        .or_else(|| {
1863            re::get(r"(?i)<w:sdtPr\b[^>]*/?>")
1864                .and_then(|re| re.find(sdt_xml))
1865                .map(|m| m.as_str().to_string())
1866        })
1867        .unwrap_or_default();
1868    let sdt_content_xml = extract_balanced_tag_blocks(sdt_xml, "w:sdtContent")
1869        .into_iter()
1870        .next()
1871        .unwrap_or_default();
1872    if sdt_properties_xml.is_empty() {
1873        return None;
1874    }
1875
1876    let alias_tag = re::get(r"(?i)<w:alias\b[^>]*/?>")
1877        .and_then(|re| re.find(&sdt_properties_xml))
1878        .map(|m| m.as_str().to_string());
1879    let tag_tag = re::get(r"(?i)<w:tag\b[^>]*/?>")
1880        .and_then(|re| re.find(&sdt_properties_xml))
1881        .map(|m| m.as_str().to_string());
1882    let id_tag = re::get(r"(?i)<w:id\b[^>]*/?>")
1883        .and_then(|re| re.find(&sdt_properties_xml))
1884        .map(|m| m.as_str().to_string());
1885    let placeholder_tag = re::get(r"(?is)<w:placeholder\b[\s\S]*?</w:placeholder>")
1886        .and_then(|re| re.find(&sdt_properties_xml))
1887        .map(|m| m.as_str().to_string());
1888    let placeholder_doc_part_tag = placeholder_tag.as_deref().and_then(|xml| {
1889        re::get(r"(?i)<w:docPart\b[^>]*/?>")
1890            .and_then(|re| re.find(xml))
1891            .map(|m| m.as_str().to_string())
1892    });
1893    let title = alias_tag
1894        .as_deref()
1895        .and_then(|tag| get_attribute(tag, "w:val"))
1896        .as_deref()
1897        .and_then(|value| decode_xml_attribute(Some(&value)));
1898    let tag = tag_tag
1899        .as_deref()
1900        .and_then(|t| get_attribute(t, "w:val"))
1901        .as_deref()
1902        .and_then(|value| decode_xml_attribute(Some(&value)));
1903    let id_value = id_tag
1904        .as_deref()
1905        .and_then(|tag| parse_integer_attribute(tag, "w:val"));
1906    let placeholder = placeholder_doc_part_tag
1907        .as_deref()
1908        .and_then(|tag| get_attribute(tag, "w:val"))
1909        .as_deref()
1910        .and_then(|value| decode_xml_attribute(Some(&value)));
1911    let content_runs = extract_balanced_tag_blocks(&sdt_content_xml, "w:r");
1912    let style = content_runs.first().and_then(|xml| {
1913        parse_run_style_in_table(xml, context, paragraph_style_id, table_run_style)
1914    });
1915    let content_text = parse_run_text(&sdt_content_xml);
1916    let trimmed_content_text = content_text.trim();
1917
1918    if re::get(r"(?i)<w14:checkbox\b").is_some_and(|re| re.is_match(&sdt_properties_xml)) {
1919        let checked_tag = re::get(r"(?i)<w14:checked\b[^>]*/?>")
1920            .and_then(|re| re.find(&sdt_properties_xml))
1921            .map(|m| m.as_str().to_string());
1922        let checked_value = checked_tag
1923            .as_deref()
1924            .and_then(|tag| get_attribute(tag, "w14:val").or_else(|| get_attribute(tag, "w:val")));
1925        let checked_state_tag = re::get(r"(?i)<w14:checkedState\b[^>]*/?>")
1926            .and_then(|re| re.find(&sdt_properties_xml))
1927            .map(|m| m.as_str().to_string());
1928        let unchecked_state_tag = re::get(r"(?i)<w14:uncheckedState\b[^>]*/?>")
1929            .and_then(|re| re.find(&sdt_properties_xml))
1930            .map(|m| m.as_str().to_string());
1931        let checked_symbol = checked_state_tag
1932            .as_deref()
1933            .and_then(|tag| {
1934                decode_hex_code_point(
1935                    get_attribute(tag, "w14:val")
1936                        .or_else(|| get_attribute(tag, "w:val"))
1937                        .as_deref(),
1938                )
1939            })
1940            .unwrap_or_else(|| "☒".to_string());
1941        let unchecked_symbol = unchecked_state_tag
1942            .as_deref()
1943            .and_then(|tag| {
1944                decode_hex_code_point(
1945                    get_attribute(tag, "w14:val")
1946                        .or_else(|| get_attribute(tag, "w:val"))
1947                        .as_deref(),
1948                )
1949            })
1950            .unwrap_or_else(|| "☐".to_string());
1951        let checked = on_off_value_to_boolean(checked_value.as_deref()).or_else(|| {
1952            if !trimmed_content_text.is_empty() {
1953                Some(trimmed_content_text.contains(&checked_symbol))
1954            } else {
1955                None
1956            }
1957        });
1958        return Some(FormFieldRunNode {
1959            r#type: FormFieldRunNodeType::FormField,
1960            field_type: FormFieldType::Checkbox,
1961            source_kind: Some(FormFieldSourceKind::Sdt),
1962            id: id_value,
1963            tag,
1964            title,
1965            placeholder,
1966            checked: Some(checked.unwrap_or(false)),
1967            value: None,
1968            options: None,
1969            widget: None,
1970            checked_symbol: Some(checked_symbol),
1971            unchecked_symbol: Some(unchecked_symbol),
1972            style,
1973            link,
1974            source_xml: Some(sdt_xml.to_string()),
1975            source_preview: None,
1976        });
1977    }
1978
1979    if re::get(r"(?i)<w:(?:dropDownList|comboBox)\b")
1980        .is_some_and(|re| re.is_match(&sdt_properties_xml))
1981    {
1982        let style = trimmed_sdt_dropdown_style(
1983            &content_runs,
1984            &content_text,
1985            style,
1986            context,
1987            paragraph_style_id,
1988            table_run_style,
1989        );
1990        let mut options = Vec::new();
1991        if let Some(re) = re::get(r"(?i)<w:listItem\b[^>]*/?>") {
1992            for mat in re.find_iter(&sdt_properties_xml) {
1993                let list_item_tag = mat.as_str();
1994                let display_text = get_attribute(list_item_tag, "w:displayText")
1995                    .and_then(|value| decode_xml_attribute(Some(&value)));
1996                let value = get_attribute(list_item_tag, "w:value")
1997                    .and_then(|value| decode_xml_attribute(Some(&value)));
1998                let Some(fallback_text) = display_text
1999                    .clone()
2000                    .or(value.clone())
2001                    .map(|t| t.trim().to_string())
2002                    .filter(|t| !t.is_empty())
2003                else {
2004                    continue;
2005                };
2006                options.push(FormFieldOption {
2007                    display_text: fallback_text,
2008                    value: value
2009                        .map(|v| v.trim().to_string())
2010                        .filter(|v| !v.is_empty()),
2011                });
2012            }
2013        }
2014        let last_value_tag = re::get(r"(?i)<w:lastValue\b[^>]*/?>")
2015            .and_then(|re| re.find(&sdt_properties_xml))
2016            .map(|m| m.as_str().to_string());
2017        let last_value = last_value_tag
2018            .as_deref()
2019            .and_then(|tag| get_attribute(tag, "w:val"))
2020            .and_then(|value| decode_xml_attribute(Some(&value)))
2021            .map(|v| v.trim().to_string())
2022            .filter(|v| !v.is_empty());
2023        let selected_value = if trimmed_content_text.is_empty() {
2024            last_value
2025        } else {
2026            Some(trimmed_content_text.to_string())
2027        };
2028        return Some(FormFieldRunNode {
2029            r#type: FormFieldRunNodeType::FormField,
2030            field_type: FormFieldType::Dropdown,
2031            source_kind: Some(FormFieldSourceKind::Sdt),
2032            id: id_value,
2033            tag,
2034            title,
2035            placeholder,
2036            checked: None,
2037            value: selected_value,
2038            options: if options.is_empty() {
2039                None
2040            } else {
2041                Some(options)
2042            },
2043            widget: None,
2044            checked_symbol: None,
2045            unchecked_symbol: None,
2046            style,
2047            link,
2048            source_xml: Some(sdt_xml.to_string()),
2049            source_preview: None,
2050        });
2051    }
2052
2053    if re::get(r"(?i)<w:date\b").is_some_and(|re| re.is_match(&sdt_properties_xml)) {
2054        let full_date_tag = re::get(r"(?i)<w:fullDate\b[^>]*/?>")
2055            .and_then(|re| re.find(&sdt_properties_xml))
2056            .map(|m| m.as_str().to_string());
2057        let full_date = full_date_tag
2058            .as_deref()
2059            .and_then(|tag| get_attribute(tag, "w:val"))
2060            .and_then(|value| decode_xml_attribute(Some(&value)))
2061            .map(|v| v.trim().to_string())
2062            .filter(|v| !v.is_empty());
2063        let value = if trimmed_content_text.is_empty() {
2064            full_date
2065        } else {
2066            Some(content_text)
2067        };
2068        return Some(FormFieldRunNode {
2069            r#type: FormFieldRunNodeType::FormField,
2070            field_type: FormFieldType::Date,
2071            source_kind: Some(FormFieldSourceKind::Sdt),
2072            id: id_value,
2073            tag,
2074            title,
2075            placeholder,
2076            checked: None,
2077            value,
2078            options: None,
2079            widget: None,
2080            checked_symbol: None,
2081            unchecked_symbol: None,
2082            style,
2083            link,
2084            source_xml: Some(sdt_xml.to_string()),
2085            source_preview: None,
2086        });
2087    }
2088
2089    if re::get(r"(?i)<w:(?:text|richText)\b").is_some_and(|re| re.is_match(&sdt_properties_xml)) {
2090        return Some(FormFieldRunNode {
2091            r#type: FormFieldRunNodeType::FormField,
2092            field_type: FormFieldType::Text,
2093            source_kind: Some(FormFieldSourceKind::Sdt),
2094            id: id_value,
2095            tag,
2096            title,
2097            placeholder,
2098            checked: None,
2099            value: Some(content_text),
2100            options: None,
2101            widget: None,
2102            checked_symbol: None,
2103            unchecked_symbol: None,
2104            style,
2105            link,
2106            source_xml: Some(sdt_xml.to_string()),
2107            source_preview: None,
2108        });
2109    }
2110
2111    None
2112}
2113
2114pub fn parse_paragraph_form_field_tokens(
2115    paragraph_xml: &str,
2116    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2117    paragraph_style_id: Option<&str>,
2118    runs: Option<&[ParagraphRunToken]>,
2119) -> Vec<ParagraphFormFieldToken> {
2120    let borrowed = runs.map(|runs| {
2121        runs.iter()
2122            .map(|run| RunToken {
2123                xml: std::borrow::Cow::Borrowed(run.xml.as_str()),
2124                start: run.start,
2125                end: run.end,
2126                link: run.link.clone(),
2127                text_links: run.text_links.clone(),
2128                empty_fields: Vec::new(),
2129            })
2130            .collect::<Vec<_>>()
2131    });
2132    parse_paragraph_form_field_tokens_in_table(
2133        paragraph_xml,
2134        context,
2135        paragraph_style_id,
2136        borrowed.as_deref(),
2137        None,
2138    )
2139}
2140
2141fn parse_paragraph_form_field_tokens_in_table(
2142    paragraph_xml: &str,
2143    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2144    paragraph_style_id: Option<&str>,
2145    runs: Option<&[RunToken<'_>]>,
2146    table_run_style: Option<&TextStyle>,
2147) -> Vec<ParagraphFormFieldToken> {
2148    let has_legacy_fields = super::scan::contains_tag(paragraph_xml, "w:fldChar");
2149    if !has_legacy_fields && !super::scan::contains_tag(paragraph_xml, "w:sdt") {
2150        return Vec::new();
2151    }
2152    let legacy_tokens = if has_legacy_fields
2153        || crate::xml::find_ignore_ascii_case(paragraph_xml, 0, "<w:fldChar").is_some()
2154    {
2155        let paragraph_runs: Vec<RunToken<'_>> = runs
2156            .map(|r| r.to_vec())
2157            .unwrap_or_else(|| paragraph_runs(paragraph_xml, context));
2158        let story_runs: Vec<_> = paragraph_runs
2159            .iter()
2160            .map(|run| RunToken {
2161                xml: std::borrow::Cow::Owned(strip_text_box_content(&run.xml)),
2162                ..run.clone()
2163            })
2164            .collect();
2165        parse_legacy_paragraph_form_field_tokens(
2166            paragraph_xml,
2167            &story_runs,
2168            context,
2169            paragraph_style_id,
2170            table_run_style,
2171        )
2172    } else {
2173        Vec::new()
2174    };
2175    let text_box_ranges = extract_balanced_tag_ranges(paragraph_xml, "w:txbxContent");
2176    let sdt_ranges: Vec<_> = extract_balanced_tag_ranges(paragraph_xml, "w:sdt")
2177        .into_iter()
2178        .filter(|range| {
2179            !text_box_ranges
2180                .iter()
2181                .any(|story| range.start >= story.start && range.end <= story.end)
2182        })
2183        .collect();
2184    if sdt_ranges.is_empty() {
2185        return legacy_tokens;
2186    }
2187
2188    let hyperlink_ranges: Vec<_> = paragraph_nested_tag_ranges(paragraph_xml, "w:hyperlink")
2189        .into_iter()
2190        .map(|range| {
2191            let hyperlink_xml = &paragraph_xml[range.start..range.end];
2192            let hyperlink_tag = re::get(r"(?i)<w:hyperlink\b[^>]*>")
2193                .and_then(|re| re.find(hyperlink_xml))
2194                .map(|m| m.as_str().to_string())
2195                .unwrap_or_default();
2196            (range, hyperlink_href_from_tag(&hyperlink_tag, context))
2197        })
2198        .collect();
2199
2200    let mut sdt_tokens = Vec::new();
2201    for range in sdt_ranges {
2202        let sdt_xml = &paragraph_xml[range.start..range.end];
2203        let link = hyperlink_ranges
2204            .iter()
2205            .rev()
2206            .find(|(hyperlink_range, _)| {
2207                range.start >= hyperlink_range.start && range.end <= hyperlink_range.end
2208            })
2209            .and_then(|(_, href)| href.clone());
2210        if let Some(field) = parse_form_field_from_sdt_xml(
2211            sdt_xml,
2212            context,
2213            paragraph_style_id,
2214            table_run_style,
2215            link,
2216        ) {
2217            sdt_tokens.push(ParagraphFormFieldToken {
2218                start: range.start,
2219                end: range.end,
2220                field,
2221            });
2222        }
2223    }
2224
2225    let mut combined = legacy_tokens;
2226    combined.extend(sdt_tokens);
2227    combined.sort_by_key(|token| token.start);
2228    combined
2229}
2230
2231fn parse_paragraph_mark_formatting(
2232    current_mark: Option<&str>,
2233    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2234    table_run_style: Option<&TextStyle>,
2235) -> crate::model::ParagraphMarkFormattingSource {
2236    let current = current_mark.unwrap_or("<w:rPr/>");
2237    let character_style = current_mark.and_then(|xml| super::styles::direct_table_property(xml, "w:rStyle"))
2238        .and_then(|tag| get_attribute(tag, "w:val"))
2239        .and_then(|id| context.styles().run_style_by_id.get(&id).cloned());
2240    crate::model::ParagraphMarkFormattingSource {
2241        default_style: super::style::cached_mark_default_style(
2242            context.styles().default_run_style.as_ref(),
2243            table_run_style,
2244        ),
2245        character_style: character_style.map(Into::into),
2246        direct_style: super::style::parse_shared_text_style_from_xml(
2247            current,
2248            &context.styles().theme_fonts,
2249        )
2250        .map(Into::into),
2251    }
2252}
2253
2254pub fn parse_paragraph(
2255    paragraph_xml: &str,
2256    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2257) -> ParagraphNode {
2258    parse_paragraph_in_table(paragraph_xml, context, None)
2259}
2260
2261pub fn parse_paragraph_in_table(
2262    paragraph_xml: &str,
2263    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2264    table_paragraph_spacing: Option<&crate::model::ParagraphSpacing>,
2265) -> ParagraphNode {
2266    parse_paragraph_with_table_run_style(paragraph_xml, context, table_paragraph_spacing, None)
2267}
2268
2269pub(crate) fn parse_paragraph_with_table_run_style(
2270    paragraph_xml: &str,
2271    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
2272    table_paragraph_spacing: Option<&crate::model::ParagraphSpacing>,
2273    table_run_style: Option<&TextStyle>,
2274) -> ParagraphNode {
2275    let mut children: Vec<ParagraphChildNode> = Vec::new();
2276    let paragraph_properties_xml =
2277        super::styles::direct_table_property(paragraph_xml, "w:pPr").unwrap_or_default();
2278    let paragraph_style = super::style::parse_paragraph_style_from_properties(
2279        paragraph_properties_xml,
2280        context,
2281        table_paragraph_spacing,
2282    );
2283    let source_paragraph_mark_properties_xml =
2284        crate::xml::direct_child_tag(paragraph_properties_xml, "w:rPr");
2285    let effective_mark_properties = source_paragraph_mark_properties_xml
2286        .map(|xml| crate::xml::without_direct_child_tags(xml, "w:rPrChange"));
2287    let paragraph_mark_style = super::style::parse_mark_run_style(
2288        effective_mark_properties.as_deref().unwrap_or_default(),
2289        context,
2290        paragraph_style.as_ref().and_then(|style| style.style_id.as_deref()),
2291        table_run_style,
2292    );
2293    let source_paragraph_mark_formatting = parse_paragraph_mark_formatting(
2294        effective_mark_properties.as_deref(),
2295        context,
2296        table_run_style,
2297    );
2298    let paragraph_mark_deleted = source_paragraph_mark_properties_xml
2299        .as_deref()
2300        .is_some_and(|xml| super::styles::direct_table_property(xml, "w:del").is_some());
2301    if let Some(runs) = indexed_plain_paragraph(paragraph_xml) {
2302        children.reserve(runs.iter().map(|run| run.run.tokens.len()).sum());
2303        for IndexedPlainParagraphRun { xml, run } in runs {
2304            let mut style = super::style::parse_run_style_from_properties(
2305                xml,
2306                run.properties,
2307                run.scripts,
2308                context,
2309                paragraph_style
2310                    .as_ref()
2311                    .and_then(|style| style.style_id.as_deref()),
2312                table_run_style,
2313            );
2314            let mut tokens = run
2315                .tokens
2316                .into_iter()
2317                .filter(|token| !token.text.is_empty())
2318                .peekable();
2319            while let Some(token) = tokens.next() {
2320                children.push(ParagraphChildNode::Text(TextRunNode {
2321                    r#type: TextRunNodeType::Text,
2322                    text: token.text,
2323                    style: if tokens.peek().is_none() {
2324                        style.take()
2325                    } else {
2326                        style.clone()
2327                    },
2328                    link: None,
2329                    note_reference: None,
2330                    field_instruction: None,
2331                }));
2332            }
2333        }
2334    } else {
2335        let runs = paragraph_runs(paragraph_xml, context);
2336        children.reserve(runs.len());
2337        let form_field_tokens = parse_paragraph_form_field_tokens_in_table(
2338            paragraph_xml,
2339            context,
2340            paragraph_style.as_ref().and_then(|s| s.style_id.as_deref()),
2341            Some(&runs),
2342            table_run_style,
2343        );
2344
2345        enum ContentToken<'a> {
2346            Run {
2347                start: usize,
2348                token: &'a RunToken<'a>,
2349            },
2350            FormField {
2351                start: usize,
2352                token: &'a ParagraphFormFieldToken,
2353            },
2354            Math {
2355                start: usize,
2356                xml: &'a str,
2357            },
2358        }
2359
2360        let mut content_tokens: Vec<ContentToken<'_>> =
2361            Vec::with_capacity(runs.len() + form_field_tokens.len());
2362        let mut form_field_token_cursor = 0usize;
2363        for run in &runs {
2364            while form_field_token_cursor < form_field_tokens.len()
2365                && form_field_tokens[form_field_token_cursor].end <= run.start
2366            {
2367                form_field_token_cursor += 1;
2368            }
2369            let current_form_field_token = form_field_tokens.get(form_field_token_cursor);
2370            let inside_form_field = current_form_field_token
2371                .is_some_and(|token| run.start >= token.start && run.end <= token.end);
2372            if inside_form_field {
2373                continue;
2374            }
2375            content_tokens.push(ContentToken::Run {
2376                start: run.start,
2377                token: run,
2378            });
2379        }
2380        for form_field_token in &form_field_tokens {
2381            content_tokens.push(ContentToken::FormField {
2382                start: form_field_token.start,
2383                token: form_field_token,
2384            });
2385        }
2386        let text_box_ranges = extract_balanced_tag_ranges(paragraph_xml, "w:txbxContent");
2387        for range in extract_balanced_tag_ranges(paragraph_xml, "m:oMath") {
2388            if !text_box_ranges
2389                .iter()
2390                .any(|story| range.start >= story.start && range.end <= story.end)
2391            {
2392                content_tokens.push(ContentToken::Math {
2393                    start: range.start,
2394                    xml: &paragraph_xml[range.start..range.end],
2395                });
2396            }
2397        }
2398        content_tokens.sort_by_key(|token| match token {
2399            ContentToken::Run { start, .. } => *start,
2400            ContentToken::FormField { start, .. } => *start,
2401            ContentToken::Math { start, .. } => *start,
2402        });
2403
2404        for content_token in content_tokens {
2405            match content_token {
2406                ContentToken::Math { xml, .. } => {
2407                    if let Some(math) = super::math::parse_math(xml) {
2408                        children.push(ParagraphChildNode::Image(Box::new(math)));
2409                    } else {
2410                        context.push_warning(
2411                            "Unable to interpret an equation; source markup is preserved",
2412                        );
2413                    }
2414                }
2415                ContentToken::FormField { token, .. } => {
2416                    children.push(ParagraphChildNode::FormField(Box::new(token.field.clone())));
2417                }
2418                ContentToken::Run { token: run, .. } => {
2419                    let plain = indexed_plain_run(&run.xml);
2420                    let style = if let Some(plain) = &plain {
2421                        super::style::parse_run_style_from_properties(
2422                            &run.xml,
2423                            plain.properties,
2424                            plain.scripts,
2425                            context,
2426                            paragraph_style.as_ref().and_then(|s| s.style_id.as_deref()),
2427                            table_run_style,
2428                        )
2429                    } else {
2430                        parse_run_style_in_table(
2431                            &run.xml,
2432                            context,
2433                            paragraph_style.as_ref().and_then(|s| s.style_id.as_deref()),
2434                            table_run_style,
2435                        )
2436                    };
2437                    if let Some(active_x_field) = (plain.is_none()
2438                        && super::scan::contains_tag(&run.xml, "w:object"))
2439                    .then(|| {
2440                        parse_run_active_x_checkbox_field_shared(
2441                            &run.xml,
2442                            context,
2443                            style.clone(),
2444                            run.link.clone(),
2445                        )
2446                    })
2447                    .flatten()
2448                    {
2449                        children.push(ParagraphChildNode::FormField(Box::new(active_x_field)));
2450                        continue;
2451                    }
2452                    let images = if plain.is_some() {
2453                        Vec::new()
2454                    } else {
2455                        parse_run_image_tokens(&run.xml, context)
2456                    };
2457                    // Text boxes own a separate story, including when their drawing
2458                    // renders as a group or cannot be represented as an image.
2459                    let parsed_tokens = if let Some(plain) = plain {
2460                        plain.tokens
2461                    } else {
2462                        let run_xml_for_text = run_text_xml(&run.xml);
2463                        match &run_xml_for_text {
2464                            std::borrow::Cow::Borrowed(xml) => parse_active_run_text_tokens(xml),
2465                            std::borrow::Cow::Owned(xml) => parse_run_text_tokens(xml),
2466                        }
2467                    };
2468                    let mut parsed_tokens: Vec<_> = parsed_tokens
2469                        .into_iter()
2470                        .filter(|token| !token.text.is_empty() || token.note_reference.is_some())
2471                        .map(|token| (token, None))
2472                        .collect();
2473                    parsed_tokens.extend(run.empty_fields.iter().map(
2474                        |(source_start, instruction)| {
2475                            (
2476                                ParsedRunTextToken {
2477                                    text: String::new(),
2478                                    source_start: *source_start,
2479                                    note_reference: None,
2480                                },
2481                                Some(instruction.clone()),
2482                            )
2483                        },
2484                    ));
2485                    parsed_tokens.sort_by_key(|(token, _)| token.source_start);
2486                    let mut parsed_tokens = parsed_tokens.into_iter().peekable();
2487                    let mut remaining_style = style;
2488                    let needs_child_ordering = !images.is_empty();
2489                    let mut ordered_children = Vec::new();
2490                    while let Some((token, field_instruction)) = parsed_tokens.next() {
2491                        let child = ParagraphChildNode::Text(TextRunNode {
2492                            r#type: TextRunNodeType::Text,
2493                            text: token.text,
2494                            style: if parsed_tokens.peek().is_none() {
2495                                remaining_style.take()
2496                            } else {
2497                                remaining_style.clone()
2498                            },
2499                            link: run
2500                                .text_links
2501                                .iter()
2502                                .rev()
2503                                .find(|(offset, _)| *offset <= token.source_start)
2504                                .and_then(|(_, link)| link.clone()),
2505                            note_reference: token.note_reference,
2506                            field_instruction,
2507                        });
2508                        if needs_child_ordering {
2509                            ordered_children.push((token.source_start, child));
2510                        } else {
2511                            children.push(child);
2512                        }
2513                    }
2514                    for (source_start, mut image) in images {
2515                        if let Some(xml) = image.source_xml.as_deref() {
2516                            let active_xml = prefer_alternate_content_choice(xml);
2517                            let text_boxes =
2518                                extract_balanced_tag_blocks(&active_xml, "w:txbxContent");
2519                            if !text_boxes.is_empty() {
2520                                image.content_nodes = Some(
2521                                    text_boxes
2522                                        .iter()
2523                                        .flat_map(|xml| {
2524                                            super::document::parse_document_xml(xml, context)
2525                                        })
2526                                        .collect(),
2527                                );
2528                                image.semantic_kind = Some("text_box".into());
2529                            }
2530                        }
2531                        ordered_children
2532                            .push((source_start, ParagraphChildNode::Image(Box::new(image))));
2533                    }
2534                    if needs_child_ordering {
2535                        ordered_children.sort_by_key(|(offset, _)| *offset);
2536                        children.extend(ordered_children.into_iter().map(|(_, child)| child));
2537                    }
2538                }
2539            }
2540        }
2541    }
2542
2543    if children.is_empty() {
2544        // An empty paragraph renders at the line height of its paragraph mark
2545        // (the pPr>rPr formatting), so the synthetic empty run must carry that
2546        // style — otherwise spacer paragraphs collapse to the default font size.
2547        children.push(ParagraphChildNode::Text(TextRunNode {
2548            r#type: TextRunNodeType::Text,
2549            text: String::new(),
2550            style: paragraph_mark_style.clone(),
2551            link: None,
2552            note_reference: None,
2553            field_instruction: None,
2554        }));
2555    }
2556
2557    let source_run_provenance = children
2558        .iter()
2559        .map(|child| match child {
2560            ParagraphChildNode::Text(run) => Some(ParagraphSourceTextPatchRun {
2561                style: run.style.clone(),
2562                link: run.link.clone(),
2563                note_reference: run.note_reference.clone(),
2564            }),
2565            _ => None,
2566        })
2567        .collect::<Option<Vec<_>>>()
2568        .map(|runs| ParagraphSourceTextPatch { runs });
2569
2570    let review_content = crate::revisions::removed_fragments(paragraph_xml)
2571        .into_iter()
2572        .map(|source| {
2573            let visible = crate::revisions::visible_removed_xml(source);
2574            let xml = format!("<w:p>{paragraph_properties_xml}{visible}</w:p>");
2575            let content = parse_paragraph_with_table_run_style(
2576                &xml, context, table_paragraph_spacing, table_run_style,
2577            );
2578            crate::revisions::ReviewContent { source_xml: source.to_owned(), children: content.children }
2579        })
2580        .collect::<Vec<_>>();
2581    ParagraphNode {
2582        review_content: (!review_content.is_empty()).then_some(review_content),
2583        r#type: ParagraphNodeType::Paragraph,
2584        block_id: None,
2585        style: paragraph_style,
2586        paragraph_mark_style: paragraph_mark_style.clone(),
2587        source_paragraph_mark_style: paragraph_mark_style,
2588        source_paragraph_mark_formatting: Some(source_paragraph_mark_formatting),
2589        source_paragraph_mark_properties_xml: source_paragraph_mark_properties_xml
2590            .map(str::to_owned),
2591        paragraph_mark_deleted: if paragraph_mark_deleted {
2592            Some(true)
2593        } else {
2594            None
2595        },
2596        children,
2597        source_xml: Some(paragraph_xml.to_string()),
2598        source_text_patch: None,
2599        source_run_provenance,
2600    }
2601}