Skip to main content

extend_docx/doc/
fmt.rs

1//! Shared primitives for mapping binary formatting structures (colors, borders,
2//! shading) onto their OOXML equivalents, plus XML string helpers.
3
4pub fn escape_xml(text: &str) -> String {
5    let mut out = String::with_capacity(text.len());
6    escape_xml_into(text, &mut out);
7    out
8}
9
10pub(super) fn escape_xml_into(text: &str, out: &mut String) {
11    let mut start = 0;
12    for (index, byte) in text.bytes().enumerate() {
13        let escaped = match byte {
14            b'&' => "&",
15            b'<' => "&lt;",
16            b'>' => "&gt;",
17            b'"' => "&quot;",
18            b'\'' => "&apos;",
19            _ => continue,
20        };
21        out.push_str(&text[start..index]);
22        out.push_str(escaped);
23        start = index + 1;
24    }
25    out.push_str(&text[start..]);
26}
27
28/// Classic 17-entry Word color palette (ico values 1-16; 0 = auto).
29const ICO_PALETTE: [&str; 17] = [
30    "auto", "000000", "0000FF", "00FFFF", "00FF00", "FF00FF", "FF0000", "FFFF00", "FFFFFF",
31    "000080", "008080", "008000", "800080", "800000", "808000", "808080", "C0C0C0",
32];
33
34pub fn ico_to_hex(ico: u8) -> Option<&'static str> {
35    match ico as usize {
36        0 => None,
37        index if index < ICO_PALETTE.len() => Some(ICO_PALETTE[index]),
38        _ => None,
39    }
40}
41
42pub fn ico_to_highlight(ico: u8) -> Option<&'static str> {
43    Some(match ico {
44        1 => "black",
45        2 => "blue",
46        3 => "cyan",
47        4 => "green",
48        5 => "magenta",
49        6 => "red",
50        7 => "yellow",
51        8 => "white",
52        9 => "darkBlue",
53        10 => "darkCyan",
54        11 => "darkGreen",
55        12 => "darkMagenta",
56        13 => "darkRed",
57        14 => "darkYellow",
58        15 => "darkGray",
59        16 => "lightGray",
60        _ => return None,
61    })
62}
63
64/// COLORREF ([MS-DOC] 2.9.39): bytes R, G, B, fAuto. Returns hex or None for auto.
65pub fn colorref_to_hex(bytes: &[u8]) -> Option<String> {
66    if bytes.len() < 4 || bytes[3] == 0xFF {
67        return None;
68    }
69    Some(format!("{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]))
70}
71
72fn brc_type_to_val(brc_type: u8) -> Option<&'static str> {
73    Some(match brc_type {
74        0 | 255 => return None,
75        1 => "single",
76        2 => "thick",
77        3 => "double",
78        5 => "single", // hairline
79        6 => "dotted",
80        7 => "dashed",
81        8 => "dotDash",
82        9 => "dotDotDash",
83        10 => "triple",
84        11 => "thinThickSmallGap",
85        12 => "thickThinSmallGap",
86        13 => "thinThickThinSmallGap",
87        14 => "thinThickMediumGap",
88        15 => "thickThinMediumGap",
89        16 => "thinThickThinMediumGap",
90        17 => "thinThickLargeGap",
91        18 => "thickThinLargeGap",
92        19 => "thinThickThinLargeGap",
93        20 => "wave",
94        21 => "doubleWave",
95        22 => "dashSmallGap",
96        23 => "dashDotStroked",
97        24 => "threeDEmboss",
98        25 => "threeDEngrave",
99        26 => "outset",
100        27 => "inset",
101        _ => "single",
102    })
103}
104
105/// A border ready for OOXML emission.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Border {
108    pub val: &'static str,
109    /// Eighths of a point (same unit as the binary dptLineWidth and OOXML w:sz).
110    pub sz: u8,
111    pub color: Option<String>,
112    pub space: u8,
113    pub shadow: bool,
114}
115
116impl Border {
117    pub fn to_xml(&self, tag: &str) -> String {
118        let mut xml = format!(
119            r#"<{tag} w:val="{}" w:sz="{}" w:space="{}""#,
120            self.val, self.sz, self.space
121        );
122        xml.push_str(&format!(
123            r#" w:color="{}""#,
124            self.color.as_deref().unwrap_or("auto")
125        ));
126        if self.shadow {
127            xml.push_str(r#" w:shadow="1""#);
128        }
129        xml.push_str("/>");
130        xml
131    }
132}
133
134/// BRC80 ([MS-DOC] 2.9.16): dptLineWidth, brcType, ico, dptSpace:5 fShadow:1 ...
135pub fn parse_brc80(bytes: &[u8]) -> Option<Border> {
136    if bytes.len() < 4 {
137        return None;
138    }
139    // 0xFFFFFFFF means "nil" (inherit / not specified).
140    if bytes[..4] == [0xFF, 0xFF, 0xFF, 0xFF] {
141        return None;
142    }
143    let val = brc_type_to_val(bytes[1])?;
144    Some(Border {
145        val,
146        sz: bytes[0],
147        color: ico_to_hex(bytes[2]).map(|hex| hex.to_string()),
148        space: bytes[3] & 0x1F,
149        shadow: bytes[3] & 0x20 != 0,
150    })
151}
152
153/// Brc ([MS-DOC] 2.9.17, Word 2000+): cv COLORREF, dptLineWidth, brcType,
154/// dptSpace:5 fShadow:1 fFrame:1.
155pub fn parse_brc97(bytes: &[u8]) -> Option<Border> {
156    if bytes.len() < 8 {
157        return None;
158    }
159    let val = brc_type_to_val(bytes[5])?;
160    Some(Border {
161        val,
162        sz: bytes[4],
163        color: colorref_to_hex(&bytes[..4]),
164        space: bytes[6] & 0x1F,
165        shadow: bytes[6] & 0x20 != 0,
166    })
167}
168
169fn ipat_to_val(ipat: u16) -> &'static str {
170    match ipat {
171        0 => "clear",
172        1 => "solid",
173        2 => "pct5",
174        3 => "pct10",
175        4 => "pct20",
176        5 => "pct25",
177        6 => "pct30",
178        7 => "pct40",
179        8 => "pct50",
180        9 => "pct60",
181        10 => "pct70",
182        11 => "pct75",
183        12 => "pct80",
184        13 => "pct90",
185        14 => "horzStripe",
186        15 => "vertStripe",
187        16 => "reverseDiagStripe",
188        17 => "diagStripe",
189        18 => "horzCross",
190        19 => "diagCross",
191        35 => "pct10",
192        _ => "clear",
193    }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Shading {
198    pub val: &'static str,
199    pub color: Option<String>,
200    pub fill: Option<String>,
201}
202
203impl Shading {
204    pub fn to_xml(&self) -> String {
205        format!(
206            r#"<w:shd w:val="{}" w:color="{}" w:fill="{}"/>"#,
207            self.val,
208            self.color.as_deref().unwrap_or("auto"),
209            self.fill.as_deref().unwrap_or("auto"),
210        )
211    }
212
213    pub fn is_visible(&self) -> bool {
214        self.val != "clear" || self.fill.is_some()
215    }
216}
217
218/// Shd80 ([MS-DOC] 2.9.245): icoFore:5 icoBack:5 ipat:6.
219pub fn parse_shd80(value: u16) -> Option<Shading> {
220    if value == 0xFFFF {
221        return None; // ShdNil
222    }
223    let ico_fore = (value & 0x1F) as u8;
224    let ico_back = ((value >> 5) & 0x1F) as u8;
225    let ipat = value >> 10;
226    Some(Shading {
227        val: ipat_to_val(ipat),
228        color: ico_to_hex(ico_fore).map(|hex| hex.to_string()),
229        fill: ico_to_hex(ico_back).map(|hex| hex.to_string()),
230    })
231}
232
233/// SHDOperand ([MS-DOC] 2.9.249): cvFore COLORREF, cvBack COLORREF, ipat u16.
234pub fn parse_shd_operand(bytes: &[u8]) -> Option<Shading> {
235    if bytes.len() < 10 {
236        return None;
237    }
238    let ipat = u16::from_le_bytes([bytes[8], bytes[9]]);
239    Some(Shading {
240        val: ipat_to_val(ipat),
241        color: colorref_to_hex(&bytes[..4]),
242        fill: colorref_to_hex(&bytes[4..8]),
243    })
244}
245
246/// Underline kind (kul) to w:u value.
247pub fn kul_to_val(kul: u8) -> &'static str {
248    match kul {
249        0 => "none",
250        1 => "single",
251        2 => "words",
252        3 => "double",
253        4 => "dotted",
254        6 => "thick",
255        7 => "dash",
256        9 => "dotDash",
257        10 => "dotDotDash",
258        11 => "wave",
259        20 => "dottedHeavy",
260        23 => "dashedHeavy",
261        25 => "dashDotHeavy",
262        26 => "dashDotDotHeavy",
263        27 => "wavyHeavy",
264        39 => "dashLong",
265        43 => "wavyDouble",
266        55 => "dashLongHeavy",
267        _ => "single",
268    }
269}
270
271/// Page-number format (nfc) to w:pgNumType w:fmt value.
272pub fn nfc_to_num_fmt(nfc: u8) -> &'static str {
273    match nfc {
274        0 => "decimal",
275        1 => "upperRoman",
276        2 => "lowerRoman",
277        3 => "upperLetter",
278        4 => "lowerLetter",
279        5 => "ordinal",
280        6 => "cardinalText",
281        7 => "ordinalText",
282        22 => "decimalZero",
283        23 => "bullet",
284        47 => "chosung",
285        _ => "decimal",
286    }
287}