Skip to main content

extend_docx/
xml.rs

1//! XML parsing utilities ported from `packages/doc-model/src/index.ts`.
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct TagRange {
5    pub start: usize,
6    pub end: usize,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TaggedRange {
11    pub start: usize,
12    pub end: usize,
13    pub tag_name: String,
14}
15
16pub(crate) fn tag_token_end(xml: &str, start: usize) -> Option<usize> {
17    let mut quote = None;
18    for (offset, byte) in xml.as_bytes().iter().copied().enumerate().skip(start) {
19        if let Some(active) = quote {
20            if byte == active {
21                quote = None;
22            }
23        } else if byte == b'\'' || byte == b'"' {
24            quote = Some(byte);
25        } else if byte == b'>' {
26            return Some(offset + 1);
27        }
28    }
29    None
30}
31
32fn visit_direct_child_tags(xml: &str, mut visit: impl FnMut(usize, usize, &str) -> bool) {
33    let Some(root_start) = xml.find('<') else {
34        return;
35    };
36    let Some(mut cursor) = tag_token_end(xml, root_start) else {
37        return;
38    };
39    let mut depth = 0usize;
40    let mut pending: Option<(&str, usize)> = None;
41    while let Some(relative_start) = xml[cursor..].find('<') {
42        let start = cursor + relative_start;
43        if xml[start..].starts_with("<!--") {
44            let Some(end) = xml[start..].find("-->") else {
45                break;
46            };
47            cursor = start + end + 3;
48            continue;
49        }
50        if xml[start..].starts_with("<![CDATA[") {
51            let Some(end) = xml[start..].find("]]>") else {
52                break;
53            };
54            cursor = start + end + 3;
55            continue;
56        }
57        let Some(end) = tag_token_end(xml, start) else {
58            break;
59        };
60        let token = &xml[start..end];
61        cursor = end;
62        if token.starts_with("<?") || token.starts_with("<!") {
63            continue;
64        }
65        if token.starts_with("</") {
66            if depth == 0 {
67                break;
68            }
69            depth -= 1;
70            if depth == 0 {
71                if let Some((name, start)) = pending.take() {
72                    if !visit(start, end, name) {
73                        break;
74                    }
75                }
76            }
77        } else {
78            if depth == 0 {
79                let name = token[1..]
80                    .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
81                    .next()
82                    .unwrap_or_default();
83                if token.ends_with("/>") {
84                    if !visit(start, end, name) {
85                        break;
86                    }
87                } else {
88                    pending = Some((name, start));
89                }
90            }
91            if !token.ends_with("/>") {
92                depth += 1;
93            }
94        }
95    }
96}
97
98pub fn extract_direct_child_tag_ranges(xml: &str) -> Vec<TaggedRange> {
99    if let Some(children) = crate::xml_index::children(xml) {
100        return children;
101    }
102    let mut children = Vec::new();
103    visit_direct_child_tags(xml, |start, end, name| {
104        children.push(TaggedRange {
105            start,
106            end,
107            tag_name: name.to_owned(),
108        });
109        true
110    });
111    children
112}
113
114pub(crate) fn direct_child_tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
115    if let Some(child) = crate::xml_index::child(xml, name) {
116        return child;
117    }
118    let mut found = None;
119    visit_direct_child_tags(xml, |start, end, tag| {
120        if tag.eq_ignore_ascii_case(name) {
121            found = Some(&xml[start..end]);
122            false
123        } else {
124            true
125        }
126    });
127    found
128}
129pub(crate) fn without_direct_child_tags<'a>(xml: &'a str, name: &str) -> std::borrow::Cow<'a, str> {
130    if direct_child_tag(xml, name).is_none() {
131        return std::borrow::Cow::Borrowed(xml);
132    }
133    let mut result = std::borrow::Cow::Borrowed(xml);
134    for range in extract_direct_child_tag_ranges(xml)
135        .into_iter()
136        .rev()
137        .filter(|range| range.tag_name.eq_ignore_ascii_case(name))
138    {
139        result.to_mut().replace_range(range.start..range.end, "");
140    }
141    result
142}
143
144pub type ParagraphAlignment = &'static str;
145
146fn is_word_char(byte: u8) -> bool {
147    byte.is_ascii_alphanumeric() || byte == b'_'
148}
149
150fn eq_ignore_ascii_case(left: &[u8], right: &[u8]) -> bool {
151    left.len() == right.len()
152        && left
153            .iter()
154            .zip(right.iter())
155            .all(|(left_byte, right_byte)| left_byte.eq_ignore_ascii_case(right_byte))
156}
157
158fn starts_with_ignore_ascii_case(haystack: &str, prefix: &str) -> bool {
159    let haystack_bytes = haystack.as_bytes();
160    let prefix_bytes = prefix.as_bytes();
161    haystack_bytes.len() >= prefix_bytes.len()
162        && eq_ignore_ascii_case(&haystack_bytes[..prefix_bytes.len()], prefix_bytes)
163}
164
165fn has_word_boundary_after(xml: &str, index: usize) -> bool {
166    xml.as_bytes()
167        .get(index)
168        .is_none_or(|byte| !is_word_char(*byte))
169}
170
171fn try_match_closing_tag(xml: &str, start: usize, tag_name: &str) -> Option<usize> {
172    if !xml.as_bytes().get(start).is_some_and(|byte| *byte == b'<') {
173        return None;
174    }
175
176    let after_lt = start + 1;
177    if !starts_with_ignore_ascii_case(&xml[after_lt..], "/") {
178        return None;
179    }
180
181    let tag_start = after_lt + 1;
182    let tag_name_bytes = tag_name.as_bytes();
183    let xml_bytes = xml.as_bytes();
184    if tag_start + tag_name_bytes.len() > xml_bytes.len() {
185        return None;
186    }
187
188    if !eq_ignore_ascii_case(
189        &xml_bytes[tag_start..tag_start + tag_name_bytes.len()],
190        tag_name_bytes,
191    ) {
192        return None;
193    }
194
195    let after_tag = tag_start + tag_name_bytes.len();
196    if xml_bytes.get(after_tag) != Some(&b'>') {
197        return None;
198    }
199
200    Some(after_tag + 1)
201}
202
203fn try_match_opening_tag(xml: &str, start: usize, tag_name: &str) -> Option<usize> {
204    if !xml.as_bytes().get(start).is_some_and(|byte| *byte == b'<') {
205        return None;
206    }
207
208    let tag_start = start + 1;
209    if xml.as_bytes().get(tag_start) == Some(&b'/') {
210        return None;
211    }
212
213    let tag_name_bytes = tag_name.as_bytes();
214    let xml_bytes = xml.as_bytes();
215    if tag_start + tag_name_bytes.len() > xml_bytes.len() {
216        return None;
217    }
218
219    if !eq_ignore_ascii_case(
220        &xml_bytes[tag_start..tag_start + tag_name_bytes.len()],
221        tag_name_bytes,
222    ) {
223        return None;
224    }
225
226    let after_tag = tag_start + tag_name_bytes.len();
227    if !has_word_boundary_after(xml, after_tag) {
228        return None;
229    }
230
231    tag_token_end(xml, after_tag)
232}
233
234fn is_self_closing_tag(token: &str) -> bool {
235    let bytes = token.as_bytes();
236    let mut index = bytes.len();
237    while index > 0 && bytes[index - 1].is_ascii_whitespace() {
238        index -= 1;
239    }
240    index >= 2 && bytes[index - 2] == b'/' && bytes[index - 1] == b'>'
241}
242
243fn find_tag_tokens<'a>(
244    xml: &'a str,
245    tag_name: &'a str,
246) -> impl Iterator<Item = (usize, usize, bool)> + 'a {
247    let mut index = 0;
248    std::iter::from_fn(move || {
249        while let Some(offset) = xml[index..].find('<') {
250            let start = index + offset;
251            if let Some(end) = try_match_closing_tag(xml, start, tag_name) {
252                index = end;
253                return Some((start, end, true));
254            }
255            if let Some(end) = try_match_opening_tag(xml, start, tag_name) {
256                index = end;
257                return Some((start, end, false));
258            }
259            index = start + 1;
260        }
261        None
262    })
263}
264
265fn decode_xml_entity(entity: &str) -> Option<String> {
266    match entity {
267        "lt" => return Some("<".to_string()),
268        "gt" => return Some(">".to_string()),
269        "quot" => return Some("\"".to_string()),
270        "apos" => return Some("'".to_string()),
271        "amp" => return Some("&".to_string()),
272        _ => {}
273    }
274
275    let (digits, radix) = entity
276        .strip_prefix("#x")
277        .or_else(|| entity.strip_prefix("#X"))
278        .map(|digits| (digits, 16))
279        .or_else(|| entity.strip_prefix('#').map(|digits| (digits, 10)))?;
280    if digits.is_empty() {
281        return None;
282    }
283
284    let code_point = u32::from_str_radix(digits, radix).ok()?;
285    if code_point == 0 {
286        return None;
287    }
288    char::from_u32(code_point).map(|character| character.to_string())
289}
290
291pub fn decode_xml_entities(text: &str) -> String {
292    let Some(first_ampersand) = text.find('&') else {
293        return text.to_string();
294    };
295
296    let mut decoded = String::with_capacity(text.len());
297    decoded.push_str(&text[..first_ampersand]);
298
299    let mut remaining = &text[first_ampersand..];
300    while let Some(ampersand_index) = remaining.find('&') {
301        decoded.push_str(&remaining[..ampersand_index]);
302        let after_ampersand = &remaining[ampersand_index + 1..];
303
304        if let Some(semicolon_index) = after_ampersand.find(';') {
305            let entity = &after_ampersand[..semicolon_index];
306            if let Some(value) = decode_xml_entity(entity) {
307                decoded.push_str(&value);
308                remaining = &after_ampersand[semicolon_index + 1..];
309                continue;
310            }
311        }
312
313        decoded.push('&');
314        remaining = after_ampersand;
315    }
316    decoded.push_str(remaining);
317    decoded
318}
319
320pub fn extract_balanced_tag_ranges(xml: &str, tag_name: &str) -> Vec<TagRange> {
321    if let Some(ranges) = crate::xml_index::ranges(xml, tag_name) {
322        return ranges;
323    }
324    let mut ranges = Vec::new();
325    let mut start_stack = Vec::new();
326
327    for (token_start, token_end, is_closing) in find_tag_tokens(xml, tag_name) {
328        let token_xml = &xml[token_start..token_end];
329
330        if is_closing {
331            let Some(start) = start_stack.pop() else {
332                continue;
333            };
334
335            if start_stack.is_empty() {
336                ranges.push(TagRange {
337                    start,
338                    end: token_end,
339                });
340            }
341            continue;
342        }
343
344        if is_self_closing_tag(token_xml) {
345            if start_stack.is_empty() {
346                ranges.push(TagRange {
347                    start: token_start,
348                    end: token_end,
349                });
350            }
351            continue;
352        }
353
354        start_stack.push(token_start);
355    }
356
357    ranges
358}
359
360pub fn extract_balanced_tag_blocks(xml: &str, tag_name: &str) -> Vec<String> {
361    extract_balanced_tag_ranges(xml, tag_name)
362        .into_iter()
363        .map(|range| xml[range.start..range.end].to_string())
364        .collect()
365}
366
367pub(crate) fn extract_balanced_tag_slices<'a>(xml: &'a str, tag_name: &str) -> Vec<&'a str> {
368    extract_balanced_tag_ranges(xml, tag_name)
369        .into_iter()
370        .map(|range| &xml[range.start..range.end])
371        .collect()
372}
373
374pub fn extract_balanced_tag_blocks_in_order(xml: &str, tag_names: &[&str]) -> Vec<TaggedRange> {
375    let mut ranges = Vec::new();
376
377    for tag_name in tag_names {
378        for range in extract_balanced_tag_ranges(xml, tag_name) {
379            ranges.push(TaggedRange {
380                start: range.start,
381                end: range.end,
382                tag_name: (*tag_name).to_string(),
383            });
384        }
385    }
386
387    ranges.sort_by(|left, right| {
388        left.start
389            .cmp(&right.start)
390            .then_with(|| left.end.cmp(&right.end))
391    });
392
393    let mut top_level_ranges: Vec<TaggedRange> = Vec::new();
394    let mut covered_end = 0;
395    for range in ranges {
396        if range.end <= covered_end {
397            continue;
398        }
399        covered_end = range.end;
400        top_level_ranges.push(range);
401    }
402
403    top_level_ranges
404}
405
406fn find_namespaced_tag_attributes<'a>(xml: &'a str, tag_name: &str) -> Option<&'a str> {
407    let mut name_buffer = [0u8; 128];
408    let qualified_name = if tag_name.len() <= name_buffer.len() - 2 {
409        name_buffer[..2].copy_from_slice(b"w:");
410        name_buffer[2..tag_name.len() + 2].copy_from_slice(tag_name.as_bytes());
411        std::borrow::Cow::Borrowed(std::str::from_utf8(&name_buffer[..tag_name.len() + 2]).ok()?)
412    } else {
413        std::borrow::Cow::Owned(format!("w:{tag_name}"))
414    };
415    let prefix_len = qualified_name.len() + 1;
416    if let Some(tag) = crate::xml_index::tag(xml, &qualified_name) {
417        return tag.map(|tag| tag[prefix_len..tag.len() - 1].trim_end_matches('/'));
418    }
419    let mut index = 0;
420
421    while index < xml.len() {
422        if xml.as_bytes()[index] != b'<' {
423            index += 1;
424            continue;
425        }
426
427        if !starts_with_ignore_ascii_case(&xml[index + 1..], &qualified_name) {
428            index += 1;
429            continue;
430        }
431
432        let after_prefix = index + prefix_len;
433        if !has_word_boundary_after(xml, after_prefix) {
434            index += 1;
435            continue;
436        }
437
438        let mut cursor = after_prefix;
439        let xml_bytes = xml.as_bytes();
440        while cursor < xml_bytes.len() {
441            let byte = xml_bytes[cursor];
442            if byte == b'>' {
443                let attributes = xml[after_prefix..cursor].trim_end_matches('/');
444                return Some(attributes);
445            }
446            cursor += 1;
447        }
448
449        return None;
450    }
451
452    None
453}
454
455fn strip_quoted_attribute(text: &str, attribute: &str) -> String {
456    let mut result = String::with_capacity(text.len());
457    let mut index = 0;
458    let bytes = text.as_bytes();
459
460    while index < bytes.len() {
461        let remaining = &text[index..];
462        let is_match = starts_with_ignore_ascii_case(remaining, attribute)
463            && has_word_boundary_before(text, index)
464            && bytes
465                .get(index + attribute.len())
466                .is_some_and(|byte| *byte == b'=')
467            && bytes
468                .get(index + attribute.len() + 1)
469                .is_some_and(|byte| *byte == b'"');
470
471        if is_match {
472            let quote_start = index + attribute.len() + 2;
473            let mut quote_end = quote_start;
474            while quote_end < bytes.len() && bytes[quote_end] != b'"' {
475                quote_end += 1;
476            }
477            if quote_end < bytes.len() {
478                index = quote_end + 1;
479                continue;
480            }
481        }
482
483        result.push(bytes[index] as char);
484        index += 1;
485    }
486
487    result
488}
489
490fn has_word_boundary_before(text: &str, index: usize) -> bool {
491    if index == 0 {
492        return true;
493    }
494    text.as_bytes()
495        .get(index - 1)
496        .is_none_or(|byte| !is_word_char(*byte))
497}
498
499fn parse_on_off_value(attributes: &str) -> bool {
500    let Some(value) = get_attribute_ref(attributes, "w:val") else {
501        return true;
502    };
503    value != "0"
504        && !value.eq_ignore_ascii_case("false")
505        && !value.eq_ignore_ascii_case("none")
506        && !value.eq_ignore_ascii_case("off")
507}
508
509pub fn parse_on_off_attribute(xml: &str, tag_name: &str) -> Option<bool> {
510    let attributes = find_namespaced_tag_attributes(xml, tag_name)?;
511    Some(parse_on_off_value(&attributes))
512}
513
514pub fn parse_underline_attribute(xml: &str) -> Option<bool> {
515    let attributes = find_namespaced_tag_attributes(xml, "u")?;
516
517    if get_attribute_ref(attributes, "w:val").is_some() {
518        return Some(parse_on_off_value(&attributes));
519    }
520
521    if attributes
522        .bytes()
523        .all(|byte| byte.is_ascii_whitespace() || byte == b'/')
524    {
525        return Some(true);
526    }
527
528    let compact_attributes: String = attributes
529        .chars()
530        .filter(|ch| !ch.is_ascii_whitespace())
531        .collect();
532    let compact_attributes = compact_attributes.trim_end_matches('/');
533
534    if compact_attributes.is_empty() {
535        return Some(true);
536    }
537
538    let non_decoration_attributes = strip_quoted_attribute(
539        &strip_quoted_attribute(
540            &strip_quoted_attribute(
541                &strip_quoted_attribute(&compact_attributes, "w:themeShade"),
542                "w:themeTint",
543            ),
544            "w:themeColor",
545        ),
546        "w:color",
547    )
548    .trim_end_matches('/')
549    .to_string();
550
551    if non_decoration_attributes.is_empty() {
552        return None;
553    }
554
555    Some(true)
556}
557
558pub fn normalize_alignment(raw_alignment: Option<&str>) -> Option<ParagraphAlignment> {
559    let value = raw_alignment?;
560    let value = value.to_ascii_lowercase();
561
562    if value == "both" || value == "distribute" || value == "thaidistribute" {
563        return Some("justify");
564    }
565
566    if matches!(value.as_str(), "left" | "center" | "right" | "justify") {
567        return Some(match value.as_str() {
568            "left" => "left",
569            "center" => "center",
570            "right" => "right",
571            "justify" => "justify",
572            _ => unreachable!(),
573        });
574    }
575
576    None
577}
578
579pub fn get_attribute(tag_xml: &str, attribute: &str) -> Option<String> {
580    get_attribute_ref(tag_xml, attribute).map(str::to_owned)
581}
582
583pub(crate) fn get_attribute_ref<'a>(tag_xml: &'a str, attribute: &str) -> Option<&'a str> {
584    let bytes = tag_xml.as_bytes();
585    let mut cursor = 0;
586    while let Some(offset) = memchr::memchr(b'=', &bytes[cursor..]) {
587        let equals = cursor + offset;
588        let mut name_end = equals;
589        while name_end > 0 && bytes[name_end - 1].is_ascii_whitespace() {
590            name_end -= 1;
591        }
592        let mut name_start = name_end;
593        while name_start > 0
594            && !bytes[name_start - 1].is_ascii_whitespace()
595            && !matches!(bytes[name_start - 1], b'<' | b'>' | b'/' | b'"' | b'\'')
596        {
597            name_start -= 1;
598        }
599        let mut quote_start = equals + 1;
600        while quote_start < bytes.len() && bytes[quote_start].is_ascii_whitespace() {
601            quote_start += 1;
602        }
603        let Some(&quote) = bytes.get(quote_start) else {
604            return None;
605        };
606        if quote != b'"' && quote != b'\'' {
607            cursor = equals + 1;
608            continue;
609        }
610        let start = quote_start + 1;
611        let end = start + memchr::memchr(quote, &bytes[start..])?;
612        if bytes[name_start..name_end].eq_ignore_ascii_case(attribute.as_bytes()) {
613            return Some(&tag_xml[start..end]);
614        }
615        cursor = end + 1;
616    }
617    None
618}
619
620pub(crate) fn find_ignore_ascii_case(haystack: &str, start: usize, needle: &str) -> Option<usize> {
621    let source = haystack.as_bytes();
622    let needle = needle.as_bytes();
623    let anchor = needle.len() / 2;
624    let byte = *needle.get(anchor)?;
625    let limit = source.len().checked_sub(needle.len())? + 1;
626    if start >= limit {
627        return None;
628    }
629    let lower = byte.to_ascii_lowercase();
630    let upper = byte.to_ascii_uppercase();
631    let search_end = limit + anchor;
632    let mut cursor = start + anchor;
633    while cursor < search_end {
634        let offset = if lower == upper {
635            memchr::memchr(byte, &source[cursor..search_end])
636        } else {
637            memchr::memchr2(lower, upper, &source[cursor..search_end])
638        }?;
639        let position = cursor + offset - anchor;
640        if source[position..position + needle.len()].eq_ignore_ascii_case(needle) {
641            return Some(position);
642        }
643        cursor = position + anchor + 1;
644    }
645    None
646}
647
648pub fn parse_integer_attribute(tag_xml: &str, attribute: &str) -> Option<i64> {
649    let raw = get_attribute_ref(tag_xml, attribute)?;
650    if raw.is_empty() {
651        return None;
652    }
653
654    let parsed = raw.trim().parse::<f64>().ok()?;
655    if !parsed.is_finite() {
656        return None;
657    }
658
659    Some(parsed.round() as i64)
660}
661
662#[cfg(test)]
663mod tests {
664    #[test]
665    fn direct_child_removal_preserves_nested_changes_and_borrows_unchanged_markup() {
666        let changed = "<w:pPr><w:spacing/><w:pPrChange><w:pPrChange/></w:pPrChange><w:pPrChange/><w:other><w:pPrChange/></w:other></w:pPr>";
667        let expected = "<w:pPr><w:spacing/><w:other><w:pPrChange/></w:other></w:pPr>";
668        let unchanged = "<w:pPr><!-- <w:pPrChange/> --><w:other><w:pPrChange/></w:other></w:pPr>";
669        for indexed in [false, true] {
670            let _scope = indexed.then(|| crate::xml_index::scope(
671                [("changed", changed), ("unchanged", unchanged)].into_iter(),
672                Default::default(),
673            ));
674            assert_eq!(super::without_direct_child_tags(changed, "w:PPRCHANGE"), expected);
675            assert!(matches!(super::without_direct_child_tags(unchanged, "w:pPrChange"), std::borrow::Cow::Borrowed(_)));
676            assert_eq!(super::direct_child_tag(changed, "w:spacing"), Some("<w:spacing/>"));
677        }
678    }
679
680    use super::*;
681
682    #[test]
683    fn decode_xml_entities_replaces_entities_in_order() {
684        assert_eq!(decode_xml_entities("a&amp;b"), "a&b");
685        assert_eq!(decode_xml_entities("&lt;tag&gt;"), "<tag>");
686        assert_eq!(decode_xml_entities("&quot;x&apos;"), "\"x'");
687        assert_eq!(decode_xml_entities("&#8226;"), "•");
688        assert_eq!(decode_xml_entities("&#x2022;"), "•");
689        assert_eq!(decode_xml_entities("&amp;#8226;"), "&#8226;");
690        assert_eq!(decode_xml_entities("&not-an-entity;"), "&not-an-entity;");
691    }
692
693    #[test]
694    fn extract_balanced_tag_ranges_matches_simple_pair() {
695        let xml = "<w:p><w:r>text</w:r></w:p>";
696        let ranges = extract_balanced_tag_ranges(xml, "w:p");
697        assert_eq!(ranges.len(), 1);
698        assert_eq!(ranges[0].start, 0);
699        assert_eq!(ranges[0].end, xml.len());
700        assert_eq!(&xml[ranges[0].start..ranges[0].end], xml);
701    }
702
703    #[test]
704    fn extract_balanced_tag_ranges_matches_self_closing_tag() {
705        let xml = "<w:br/>";
706        let ranges = extract_balanced_tag_ranges(xml, "w:br");
707        assert_eq!(
708            ranges,
709            vec![TagRange {
710                start: 0,
711                end: xml.len()
712            }]
713        );
714    }
715
716    #[test]
717    fn extract_balanced_tag_ranges_ignores_nested_same_tag() {
718        let xml = "<w:p><w:p>inner</w:p></w:p>";
719        let ranges = extract_balanced_tag_ranges(xml, "w:p");
720        assert_eq!(ranges.len(), 1);
721        assert_eq!(&xml[ranges[0].start..ranges[0].end], xml);
722    }
723
724    #[test]
725    fn extract_balanced_tag_ranges_ignores_nested_self_closing_tag() {
726        let xml = "<w:p><w:p/></w:p>";
727        let ranges = extract_balanced_tag_ranges(xml, "w:p");
728        assert_eq!(ranges.len(), 1);
729        assert_eq!(ranges[0].start, 0);
730        assert_eq!(ranges[0].end, xml.len());
731    }
732
733    #[test]
734    fn extract_balanced_tag_ranges_returns_multiple_top_level_ranges() {
735        let xml = "<w:p>a</w:p><w:p>b</w:p>";
736        let ranges = extract_balanced_tag_ranges(xml, "w:p");
737        assert_eq!(ranges.len(), 2);
738        assert_eq!(&xml[ranges[0].start..ranges[0].end], "<w:p>a</w:p>");
739        assert_eq!(&xml[ranges[1].start..ranges[1].end], "<w:p>b</w:p>");
740    }
741
742    #[test]
743    fn extract_balanced_tag_ranges_is_case_insensitive() {
744        let xml = "<W:P>text</W:P>";
745        let ranges = extract_balanced_tag_ranges(xml, "w:p");
746        assert_eq!(ranges.len(), 1);
747        assert_eq!(&xml[ranges[0].start..ranges[0].end], xml);
748    }
749
750    #[test]
751    fn extract_balanced_tag_blocks_returns_slices() {
752        let xml = "<w:p>one</w:p><w:p>two</w:p>";
753        let blocks = extract_balanced_tag_blocks(xml, "w:p");
754        assert_eq!(blocks, vec!["<w:p>one</w:p>", "<w:p>two</w:p>"]);
755    }
756
757    #[test]
758    fn extract_balanced_tag_blocks_in_order_filters_nested_ranges() {
759        let xml = "<w:tbl><w:tr><w:tc><w:p>cell</w:p></w:tc></w:tr></w:tbl>";
760        let ranges = extract_balanced_tag_blocks_in_order(xml, &["w:tbl", "w:tr", "w:tc", "w:p"]);
761        assert_eq!(ranges.len(), 1);
762        assert_eq!(ranges[0].tag_name, "w:tbl");
763        assert_eq!(ranges[0].start, 0);
764        assert_eq!(ranges[0].end, xml.len());
765    }
766
767    #[test]
768    fn extract_balanced_tag_blocks_in_order_sorts_by_start_and_end() {
769        let xml = "<w:p/><w:tbl><w:tr/></w:tbl>";
770        let ranges = extract_balanced_tag_blocks_in_order(xml, &["w:tbl", "w:p", "w:tr"]);
771        assert_eq!(ranges.len(), 2);
772        assert_eq!(ranges[0].tag_name, "w:p");
773        assert_eq!(ranges[1].tag_name, "w:tbl");
774    }
775
776    #[test]
777    fn parse_on_off_attribute_defaults_to_true_without_val() {
778        assert_eq!(parse_on_off_attribute("<w:b/>", "b"), Some(true));
779    }
780
781    #[test]
782    fn parse_on_off_attribute_parses_false_values() {
783        assert_eq!(
784            parse_on_off_attribute(r#"<w:b w:val="false"/>"#, "b"),
785            Some(false)
786        );
787        assert_eq!(
788            parse_on_off_attribute(r#"<w:i w:val="0"/>"#, "i"),
789            Some(false)
790        );
791    }
792
793    #[test]
794    fn parse_underline_attribute_handles_color_only_tags() {
795        assert_eq!(
796            parse_underline_attribute(r#"<w:u w:color="FFFFFF"/>"#),
797            None
798        );
799        assert_eq!(parse_underline_attribute("<w:u/>"), Some(true));
800        assert_eq!(
801            parse_underline_attribute(r#"<w:u w:val="single"/>"#),
802            Some(true)
803        );
804    }
805
806    #[test]
807    fn normalize_alignment_maps_distribute_to_justify() {
808        assert_eq!(normalize_alignment(Some("distribute")), Some("justify"));
809        assert_eq!(normalize_alignment(Some("center")), Some("center"));
810        assert_eq!(normalize_alignment(Some("bogus")), None);
811    }
812
813    #[test]
814    fn get_attribute_reads_double_and_single_quoted_values() {
815        assert_eq!(
816            get_attribute(r#"foo="bar" baz='qux'"#, "foo"),
817            Some("bar".to_string())
818        );
819        assert_eq!(
820            get_attribute(r#"foo='bar'"#, "foo"),
821            Some("bar".to_string())
822        );
823    }
824
825    #[test]
826    fn parse_integer_attribute_rounds_numeric_values() {
827        assert_eq!(
828            parse_integer_attribute(r#"w:spacing w:val="3.6""#, "w:val"),
829            Some(4)
830        );
831        assert_eq!(
832            parse_integer_attribute(r#"w:spacing w:val="2""#, "w:val"),
833            Some(2)
834        );
835        assert_eq!(
836            parse_integer_attribute(r#"w:spacing w:val="abc""#, "w:val"),
837            None
838        );
839    }
840}