1#[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("e) = 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&b"), "a&b");
685 assert_eq!(decode_xml_entities("<tag>"), "<tag>");
686 assert_eq!(decode_xml_entities(""x'"), "\"x'");
687 assert_eq!(decode_xml_entities("•"), "•");
688 assert_eq!(decode_xml_entities("•"), "•");
689 assert_eq!(decode_xml_entities("&#8226;"), "•");
690 assert_eq!(decode_xml_entities("¬-an-entity;"), "¬-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}