Skip to main content

rdocx_oxml/
borders.rs

1//! Border and tab stop types for paragraph formatting.
2
3use quick_xml::events::{BytesEnd, BytesStart, Event};
4use quick_xml::{Reader, Writer, XmlVersion};
5
6use crate::error::{OxmlError, Result};
7use crate::namespace::{W_NS, matches_local_name};
8use crate::shared::{ST_Border, ST_TabJc, ST_TabLeader};
9use crate::units::Twips;
10
11const MAX_TAB_XML_DEPTH: usize = 64;
12
13/// A single border edge (top, bottom, left, right, between).
14#[derive(Debug, Clone, PartialEq)]
15pub struct CT_BorderEdge {
16    /// Border style
17    pub val: ST_Border,
18    /// Border width in eighths of a point
19    pub sz: Option<u32>,
20    /// Space between border and content in points
21    pub space: Option<u32>,
22    /// Border color as hex, e.g. "FF0000"
23    pub color: Option<String>,
24}
25
26impl CT_BorderEdge {
27    pub fn new(val: ST_Border) -> Self {
28        CT_BorderEdge {
29            val,
30            sz: None,
31            space: None,
32            color: None,
33        }
34    }
35
36    pub fn from_xml_attrs(e: &BytesStart) -> Result<Self> {
37        let mut val = ST_Border::None;
38        let mut sz = None;
39        let mut space = None;
40        let mut color = None;
41
42        for attr in e.attributes() {
43            let attr = attr?;
44            let key = attr.key.as_ref();
45            let v = std::str::from_utf8(&attr.value)?;
46            if matches_local_name(key, b"val") {
47                val = ST_Border::from_str(v)?;
48            } else if matches_local_name(key, b"sz") {
49                sz = Some(v.parse()?);
50            } else if matches_local_name(key, b"space") {
51                space = Some(v.parse()?);
52            } else if matches_local_name(key, b"color") {
53                color = Some(v.to_string());
54            }
55        }
56
57        Ok(CT_BorderEdge {
58            val,
59            sz,
60            space,
61            color,
62        })
63    }
64
65    pub fn write_xml_attrs(&self, e: &mut BytesStart) {
66        let mut buf = itoa::Buffer::new();
67        e.push_attribute(("w:val", self.val.to_str()));
68        if let Some(sz) = self.sz {
69            e.push_attribute(("w:sz", buf.format(sz)));
70        }
71        if let Some(space) = self.space {
72            e.push_attribute(("w:space", buf.format(space)));
73        }
74        if let Some(ref color) = self.color {
75            e.push_attribute(("w:color", color.as_str()));
76        }
77    }
78
79    /// Write this border edge as an empty element with the given tag name.
80    pub fn to_xml<W: std::io::Write>(
81        &self,
82        writer: &mut Writer<W>,
83        tag: &str,
84    ) -> crate::error::Result<()> {
85        let mut e = BytesStart::new(tag);
86        self.write_xml_attrs(&mut e);
87        writer.write_event(Event::Empty(e))?;
88        Ok(())
89    }
90}
91
92/// `CT_PBdr` — Paragraph borders (top, bottom, left, right, between, bar).
93#[derive(Debug, Clone, Default, PartialEq)]
94pub struct CT_PBdr {
95    pub top: Option<CT_BorderEdge>,
96    pub bottom: Option<CT_BorderEdge>,
97    pub left: Option<CT_BorderEdge>,
98    pub right: Option<CT_BorderEdge>,
99    pub between: Option<CT_BorderEdge>,
100    pub bar: Option<CT_BorderEdge>,
101}
102
103impl CT_PBdr {
104    pub fn from_xml(reader: &mut Reader<&[u8]>) -> Result<Self> {
105        let mut bdr = CT_PBdr::default();
106        let mut buf = Vec::new();
107
108        loop {
109            match reader.read_event_into(&mut buf) {
110                Ok(Event::Empty(ref e)) => {
111                    let name = e.name();
112                    let edge = CT_BorderEdge::from_xml_attrs(e)?;
113                    if matches_local_name(name.as_ref(), b"top") {
114                        bdr.top = Some(edge);
115                    } else if matches_local_name(name.as_ref(), b"bottom") {
116                        bdr.bottom = Some(edge);
117                    } else if matches_local_name(name.as_ref(), b"left")
118                        || matches_local_name(name.as_ref(), b"start")
119                    {
120                        bdr.left = Some(edge);
121                    } else if matches_local_name(name.as_ref(), b"right")
122                        || matches_local_name(name.as_ref(), b"end")
123                    {
124                        bdr.right = Some(edge);
125                    } else if matches_local_name(name.as_ref(), b"between") {
126                        bdr.between = Some(edge);
127                    } else if matches_local_name(name.as_ref(), b"bar") {
128                        bdr.bar = Some(edge);
129                    }
130                }
131                Ok(Event::End(ref e)) if matches_local_name(e.name().as_ref(), b"pBdr") => {
132                    break;
133                }
134                Ok(Event::Eof) => break,
135                Err(e) => return Err(e.into()),
136                _ => {}
137            }
138            buf.clear();
139        }
140
141        Ok(bdr)
142    }
143
144    pub fn to_xml<W: std::io::Write>(&self, writer: &mut Writer<W>) -> Result<()> {
145        writer.write_event(Event::Start(BytesStart::new("w:pBdr")))?;
146
147        if let Some(ref edge) = self.top {
148            let mut e = BytesStart::new("w:top");
149            edge.write_xml_attrs(&mut e);
150            writer.write_event(Event::Empty(e))?;
151        }
152        if let Some(ref edge) = self.left {
153            let mut e = BytesStart::new("w:left");
154            edge.write_xml_attrs(&mut e);
155            writer.write_event(Event::Empty(e))?;
156        }
157        if let Some(ref edge) = self.bottom {
158            let mut e = BytesStart::new("w:bottom");
159            edge.write_xml_attrs(&mut e);
160            writer.write_event(Event::Empty(e))?;
161        }
162        if let Some(ref edge) = self.right {
163            let mut e = BytesStart::new("w:right");
164            edge.write_xml_attrs(&mut e);
165            writer.write_event(Event::Empty(e))?;
166        }
167        if let Some(ref edge) = self.between {
168            let mut e = BytesStart::new("w:between");
169            edge.write_xml_attrs(&mut e);
170            writer.write_event(Event::Empty(e))?;
171        }
172        if let Some(ref edge) = self.bar {
173            let mut e = BytesStart::new("w:bar");
174            edge.write_xml_attrs(&mut e);
175            writer.write_event(Event::Empty(e))?;
176        }
177
178        writer.write_event(Event::End(BytesEnd::new("w:pBdr")))?;
179        Ok(())
180    }
181
182    pub fn is_empty(&self) -> bool {
183        self.top.is_none()
184            && self.bottom.is_none()
185            && self.left.is_none()
186            && self.right.is_none()
187            && self.between.is_none()
188            && self.bar.is_none()
189    }
190}
191
192/// A single tab stop definition.
193#[derive(Debug, Clone)]
194pub struct CT_TabStop {
195    /// Tab stop alignment
196    pub val: ST_TabJc,
197    /// Position in twips
198    pub pos: Twips,
199    /// Leader character
200    pub leader: Option<ST_TabLeader>,
201    /// Original occurrence in a parsed tab collection, or `None` for a new tab.
202    ///
203    /// This preservation value is ignored by semantic equality. Callers moving
204    /// a tab into another `CT_Tabs` collection should set it to `None`.
205    pub source_occurrence: Option<usize>,
206}
207
208impl PartialEq for CT_TabStop {
209    fn eq(&self, other: &Self) -> bool {
210        self.val == other.val && self.pos == other.pos && self.leader == other.leader
211    }
212}
213
214impl CT_TabStop {
215    pub fn new(val: ST_TabJc, pos: Twips) -> Self {
216        CT_TabStop {
217            val,
218            pos,
219            leader: None,
220            source_occurrence: None,
221        }
222    }
223
224    pub fn from_xml_attrs(e: &BytesStart) -> Result<Self> {
225        Self::from_xml_attrs_with_prefixes(e, &["w".to_string()])
226    }
227
228    /// Parse a tab stop using the in-scope WordprocessingML prefixes.
229    pub fn from_xml_attrs_with_prefixes(e: &BytesStart, word_prefixes: &[String]) -> Result<Self> {
230        let prefixes = word_prefixes_at(e, word_prefixes)?;
231        let mut val = ST_TabJc::Left;
232        let mut pos = Twips(0);
233        let mut leader = None;
234
235        for attr in e.attributes() {
236            let attr = attr?;
237            let key = attr.key.as_ref();
238            let v = std::str::from_utf8(&attr.value)?;
239            if is_word_attribute(key, b"val", &prefixes) {
240                val = ST_TabJc::from_str(v)?;
241            } else if is_word_attribute(key, b"pos", &prefixes) {
242                pos = Twips(v.parse()?);
243            } else if is_word_attribute(key, b"leader", &prefixes) {
244                leader = Some(ST_TabLeader::from_str(v)?);
245            }
246        }
247
248        Ok(CT_TabStop {
249            val,
250            pos,
251            leader,
252            source_occurrence: None,
253        })
254    }
255}
256
257fn word_prefixes_at(start: &BytesStart<'_>, inherited: &[String]) -> Result<Vec<String>> {
258    let mut prefixes = inherited.to_vec();
259    for attribute in start.attributes() {
260        let attribute = attribute?;
261        let name = attribute.key.as_ref();
262        let prefix = if name == b"xmlns" {
263            b"".as_slice()
264        } else if let Some(prefix) = name.strip_prefix(b"xmlns:") {
265            prefix
266        } else {
267            continue;
268        };
269        let prefix = std::str::from_utf8(prefix)?.to_string();
270        prefixes.retain(|candidate| candidate != &prefix);
271        let value =
272            attribute.decoded_and_normalized_value(XmlVersion::Implicit1_0, start.decoder())?;
273        if value.as_bytes() == W_NS.as_bytes() {
274            prefixes.push(prefix);
275        }
276    }
277    Ok(prefixes)
278}
279
280fn is_word_name(name: &[u8], word_prefixes: &[String]) -> bool {
281    let Some(separator) = name.iter().position(|byte| *byte == b':') else {
282        return word_prefixes.iter().any(String::is_empty);
283    };
284    word_prefixes
285        .iter()
286        .any(|prefix| prefix.as_bytes() == &name[..separator])
287}
288
289fn is_word_element(name: &[u8], local: &[u8], word_prefixes: &[String]) -> bool {
290    matches_local_name(name, local) && is_word_name(name, word_prefixes)
291}
292
293fn is_word_attribute(key: &[u8], local: &[u8], word_prefixes: &[String]) -> bool {
294    let Some(separator) = key.iter().position(|byte| *byte == b':') else {
295        return false;
296    };
297    key.get(separator + 1..) == Some(local)
298        && word_prefixes
299            .iter()
300            .any(|prefix| prefix.as_bytes() == &key[..separator])
301}
302
303/// `CT_Tabs` — Collection of tab stop definitions.
304#[derive(Debug, Clone, Default, PartialEq)]
305pub struct CT_Tabs {
306    pub tabs: Vec<CT_TabStop>,
307}
308
309impl CT_Tabs {
310    pub fn from_xml(reader: &mut Reader<&[u8]>) -> Result<Self> {
311        Self::from_xml_with_prefixes(reader, &["w".to_string()])
312    }
313
314    /// Parse tab stops using the in-scope WordprocessingML prefixes.
315    pub fn from_xml_with_prefixes(
316        reader: &mut Reader<&[u8]>,
317        word_prefixes: &[String],
318    ) -> Result<Self> {
319        let mut tabs = Vec::new();
320        let mut buf = Vec::new();
321        let mut scopes = vec![word_prefixes.to_vec()];
322
323        loop {
324            match reader.read_event_into(&mut buf) {
325                Ok(Event::Empty(ref e)) => {
326                    let prefixes = word_prefixes_at(e, scopes.last().expect("outer scope"))?;
327                    if scopes.len() == 1 && is_word_element(e.name().as_ref(), b"tab", &prefixes) {
328                        let mut tab = CT_TabStop::from_xml_attrs_with_prefixes(e, &prefixes)?;
329                        tab.source_occurrence = Some(tabs.len());
330                        tabs.push(tab);
331                    }
332                }
333                Ok(Event::Start(ref e)) => {
334                    if scopes.len() >= MAX_TAB_XML_DEPTH {
335                        return Err(OxmlError::InvalidValue(format!(
336                            "tab XML depth exceeds {MAX_TAB_XML_DEPTH}"
337                        )));
338                    }
339                    let prefixes = word_prefixes_at(e, scopes.last().expect("outer scope"))?;
340                    if scopes.len() == 1 && is_word_element(e.name().as_ref(), b"tab", &prefixes) {
341                        let mut tab = CT_TabStop::from_xml_attrs_with_prefixes(e, &prefixes)?;
342                        tab.source_occurrence = Some(tabs.len());
343                        tabs.push(tab);
344                    }
345                    scopes.push(prefixes);
346                }
347                Ok(Event::End(ref e)) => {
348                    if scopes.len() == 1 && is_word_element(e.name().as_ref(), b"tabs", &scopes[0])
349                    {
350                        break;
351                    }
352                    if scopes.len() > 1 {
353                        scopes.pop();
354                    }
355                }
356                Ok(Event::Eof) => break,
357                Err(e) => return Err(e.into()),
358                _ => {}
359            }
360            buf.clear();
361        }
362
363        Ok(CT_Tabs { tabs })
364    }
365
366    pub fn to_xml<W: std::io::Write>(&self, writer: &mut Writer<W>) -> Result<()> {
367        if self.tabs.is_empty() {
368            return Ok(());
369        }
370
371        writer.write_event(Event::Start(BytesStart::new("w:tabs")))?;
372
373        let mut buf = itoa::Buffer::new();
374        for tab in &self.tabs {
375            let mut e = BytesStart::new("w:tab");
376            e.push_attribute(("w:val", tab.val.to_str()));
377            e.push_attribute(("w:pos", buf.format(tab.pos.0)));
378            if let Some(leader) = tab.leader {
379                e.push_attribute(("w:leader", leader.to_str()));
380            }
381            writer.write_event(Event::Empty(e))?;
382        }
383
384        writer.write_event(Event::End(BytesEnd::new("w:tabs")))?;
385        Ok(())
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn round_trip_borders() {
395        let bdr = CT_PBdr {
396            top: Some(CT_BorderEdge {
397                val: ST_Border::Single,
398                sz: Some(4),
399                space: Some(1),
400                color: Some("000000".to_string()),
401            }),
402            bottom: Some(CT_BorderEdge {
403                val: ST_Border::Double,
404                sz: Some(6),
405                space: Some(2),
406                color: Some("FF0000".to_string()),
407            }),
408            ..Default::default()
409        };
410
411        let mut output = Vec::new();
412        let mut writer = Writer::new(&mut output);
413        bdr.to_xml(&mut writer).unwrap();
414        let xml = String::from_utf8(output).unwrap();
415
416        let full = xml.to_string();
417        let mut reader = Reader::from_str(&full);
418        reader.config_mut().trim_text(true);
419        let mut buf = Vec::new();
420        // Skip to pBdr start
421        loop {
422            match reader.read_event_into(&mut buf) {
423                Ok(Event::Start(ref e)) if matches_local_name(e.name().as_ref(), b"pBdr") => {
424                    break;
425                }
426                _ => {}
427            }
428            buf.clear();
429        }
430        let parsed = CT_PBdr::from_xml(&mut reader).unwrap();
431
432        assert_eq!(parsed.top.as_ref().unwrap().val, ST_Border::Single);
433        assert_eq!(parsed.top.as_ref().unwrap().sz, Some(4));
434        assert_eq!(parsed.bottom.as_ref().unwrap().val, ST_Border::Double);
435        assert!(parsed.left.is_none());
436    }
437
438    #[test]
439    fn round_trip_tabs() {
440        let tabs = CT_Tabs {
441            tabs: vec![
442                CT_TabStop {
443                    val: ST_TabJc::Left,
444                    pos: Twips(720),
445                    leader: None,
446                    source_occurrence: None,
447                },
448                CT_TabStop {
449                    val: ST_TabJc::Center,
450                    pos: Twips(4320),
451                    leader: Some(ST_TabLeader::Dot),
452                    source_occurrence: None,
453                },
454                CT_TabStop {
455                    val: ST_TabJc::Right,
456                    pos: Twips(8640),
457                    leader: Some(ST_TabLeader::Hyphen),
458                    source_occurrence: None,
459                },
460            ],
461        };
462
463        let mut output = Vec::new();
464        let mut writer = Writer::new(&mut output);
465        tabs.to_xml(&mut writer).unwrap();
466        let xml = String::from_utf8(output).unwrap();
467
468        let mut reader = Reader::from_str(&xml);
469        reader.config_mut().trim_text(true);
470        let mut buf = Vec::new();
471        loop {
472            match reader.read_event_into(&mut buf) {
473                Ok(Event::Start(ref e)) if matches_local_name(e.name().as_ref(), b"tabs") => {
474                    break;
475                }
476                _ => {}
477            }
478            buf.clear();
479        }
480        let parsed = CT_Tabs::from_xml(&mut reader).unwrap();
481
482        assert_eq!(parsed.tabs.len(), 3);
483        assert_eq!(parsed.tabs[0].val, ST_TabJc::Left);
484        assert_eq!(parsed.tabs[0].pos, Twips(720));
485        assert_eq!(parsed.tabs[1].val, ST_TabJc::Center);
486        assert_eq!(parsed.tabs[1].leader, Some(ST_TabLeader::Dot));
487        assert_eq!(parsed.tabs[2].val, ST_TabJc::Right);
488        assert_eq!(parsed.tabs[0].source_occurrence, Some(0));
489        assert_eq!(parsed.tabs[1].source_occurrence, Some(1));
490    }
491
492    #[test]
493    fn namespace_aware_tabs_ignore_foreign_same_local_children() {
494        let xml = r#"<q:tabs xmlns:q="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:ext="urn:producer"><ext:tab ext:val="right" ext:pos="99"/><q:tab q:val="left" q:pos="720"/></q:tabs>"#;
495        let mut reader = Reader::from_str(xml);
496        let mut buf = Vec::new();
497        loop {
498            match reader.read_event_into(&mut buf) {
499                Ok(Event::Start(_)) => break,
500                Ok(Event::Eof) => panic!("missing tabs start"),
501                _ => {}
502            }
503            buf.clear();
504        }
505        let parsed = CT_Tabs::from_xml_with_prefixes(&mut reader, &["q".to_string()]).unwrap();
506        assert_eq!(parsed.tabs.len(), 1);
507        assert_eq!(parsed.tabs[0].pos, Twips(720));
508        assert_eq!(parsed.tabs[0].source_occurrence, Some(0));
509
510        let mut constructed = CT_TabStop::new(ST_TabJc::Left, Twips(720));
511        assert_eq!(parsed.tabs[0], constructed);
512        constructed.source_occurrence = Some(99);
513        assert_eq!(parsed.tabs[0], constructed);
514    }
515
516    #[test]
517    fn namespace_aware_tabs_track_shadows_and_expanded_tab_elements() {
518        let xml = r#"<q:tabs xmlns:q="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><foreign xmlns="urn:producer" xmlns:q="urn:producer"><q:tab q:val="right" q:pos="99"/><q:tabs><q:tab q:val="right" q:pos="100"/></q:tabs></foreign><q:tab q:val="left" q:pos="720"></q:tab><q:tab q:val="right" q:pos="1440"/></q:tabs>"#;
519        let mut reader = Reader::from_str(xml);
520        let mut buf = Vec::new();
521        loop {
522            match reader.read_event_into(&mut buf) {
523                Ok(Event::Start(_)) => break,
524                Ok(Event::Eof) => panic!("missing tabs start"),
525                event => {
526                    event.unwrap();
527                }
528            }
529            buf.clear();
530        }
531
532        let parsed = CT_Tabs::from_xml_with_prefixes(&mut reader, &["q".to_string()]).unwrap();
533        assert_eq!(parsed.tabs.len(), 2);
534        assert_eq!(parsed.tabs[0].pos, Twips(720));
535        assert_eq!(parsed.tabs[0].source_occurrence, Some(0));
536        assert_eq!(parsed.tabs[1].pos, Twips(1440));
537        assert_eq!(parsed.tabs[1].source_occurrence, Some(1));
538
539        let default_xml = r#"<tabs xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><foreign xmlns="urn:producer"><tab val="right" pos="99"/><tabs><tab val="right" pos="100"/></tabs></foreign><tab xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:val="center" w:pos="2160"></tab></tabs>"#;
540        let mut reader = Reader::from_str(default_xml);
541        let mut buf = Vec::new();
542        loop {
543            match reader.read_event_into(&mut buf) {
544                Ok(Event::Start(_)) => break,
545                Ok(Event::Eof) => panic!("missing default tabs start"),
546                event => {
547                    event.unwrap();
548                }
549            }
550            buf.clear();
551        }
552        let parsed = CT_Tabs::from_xml_with_prefixes(&mut reader, &[String::new()]).unwrap();
553        assert_eq!(parsed.tabs.len(), 1);
554        assert_eq!(parsed.tabs[0].val, ST_TabJc::Center);
555        assert_eq!(parsed.tabs[0].pos, Twips(2160));
556        assert_eq!(parsed.tabs[0].source_occurrence, Some(0));
557    }
558
559    #[test]
560    fn namespace_aware_tabs_reject_deep_distinct_aliases_normally() {
561        let mut xml = format!(r#"<q:tabs xmlns:q="{W_NS}">"#);
562        for depth in 0..MAX_TAB_XML_DEPTH {
563            xml.push_str(&format!(r#"<n{depth}:unknown xmlns:n{depth}="{W_NS}">"#));
564        }
565        for depth in (0..MAX_TAB_XML_DEPTH).rev() {
566            xml.push_str(&format!("</n{depth}:unknown>"));
567        }
568        xml.push_str("</q:tabs>");
569
570        let mut reader = Reader::from_str(&xml);
571        let mut buf = Vec::new();
572        loop {
573            match reader.read_event_into(&mut buf) {
574                Ok(Event::Start(_)) => break,
575                Ok(Event::Eof) => panic!("missing tabs start"),
576                event => {
577                    event.unwrap();
578                }
579            }
580            buf.clear();
581        }
582        let error = CT_Tabs::from_xml_with_prefixes(&mut reader, &["q".to_string()])
583            .expect_err("deep alias nesting must be bounded");
584        assert!(error.to_string().contains("tab XML depth exceeds 64"));
585    }
586
587    #[test]
588    fn border_edge_all_styles_round_trip() {
589        // Test that all border styles serialize and deserialize correctly
590        let styles = [
591            ST_Border::None,
592            ST_Border::Single,
593            ST_Border::Thick,
594            ST_Border::Double,
595            ST_Border::Dotted,
596            ST_Border::Dashed,
597            ST_Border::DotDash,
598            ST_Border::Wave,
599        ];
600
601        for &style in &styles {
602            let bdr = CT_PBdr {
603                top: Some(CT_BorderEdge {
604                    val: style,
605                    sz: Some(8),
606                    space: Some(0),
607                    color: Some("FF00FF".to_string()),
608                }),
609                ..Default::default()
610            };
611
612            let mut output = Vec::new();
613            let mut writer = Writer::new(&mut output);
614            bdr.to_xml(&mut writer).unwrap();
615            let xml = String::from_utf8(output).unwrap();
616
617            let mut reader = Reader::from_str(&xml);
618            reader.config_mut().trim_text(true);
619            let mut buf = Vec::new();
620            loop {
621                match reader.read_event_into(&mut buf) {
622                    Ok(Event::Start(ref e)) if matches_local_name(e.name().as_ref(), b"pBdr") => {
623                        break;
624                    }
625                    _ => {}
626                }
627                buf.clear();
628            }
629            let parsed = CT_PBdr::from_xml(&mut reader).unwrap();
630            let top = parsed.top.as_ref().unwrap();
631            assert_eq!(
632                top.val, style,
633                "Border style round-trip failed for {style:?}"
634            );
635            assert_eq!(top.sz, Some(8));
636            assert_eq!(top.color.as_deref(), Some("FF00FF"));
637        }
638    }
639}