Skip to main content

xlsx_rs/
shared_strings.rs

1//! SharedStrings Table:`.xlsx` 内的共享字符串表。
2//!
3//! 对应 OOXML SpreadsheetML 的 `xl/sharedStrings.xml` part,
4//! 根元素为 `<sst>`(CT_SharedStrings)。
5//!
6//! # 设计要点
7//!
8//! Excel 把所有文本字符串集中存放在 `sharedStrings.xml` 中,
9//! 单元格(`<c t="s"><v>i</v></c>`)通过索引引用。
10//! 这样可以大幅减小文件体积(重复字符串只存一份)。
11//!
12//! - `count`:总引用次数(所有 cell 引用的总和);
13//! - `uniqueCount`:唯一字符串数(`<si>` 元素个数)。
14//!
15//! 本实现用 `Vec<String>` 存储字符串,`HashMap<String, usize>` 做反向索引,
16//! `add()` 时自动去重(同一字符串返回同一索引)。
17
18use std::collections::HashMap;
19
20use crate::error::{Error, Result};
21
22/// SpreadsheetML 命名空间。
23const NS_SPREADSHEETML: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
24
25/// 共享字符串表。
26///
27/// 对应 `xl/sharedStrings.xml` 的 `<sst>` 根元素。
28#[derive(Debug, Clone, Default)]
29pub struct SharedStringsTable {
30    /// 字符串列表(按插入顺序,索引即位置)。
31    strings: Vec<String>,
32    /// 反向索引:字符串内容 → 在 `strings` 中的索引。
33    /// 用于 `add()` 时去重,避免相同字符串多次存储。
34    index: HashMap<String, usize>,
35    /// 总引用次数(所有 cell 引用的总和,用于写出 `count` 属性)。
36    /// 注意:`uniqueCount = strings.len()`。
37    reference_count: usize,
38}
39
40impl SharedStringsTable {
41    /// 构造空表。
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// 添加一个字符串,返回其索引。
47    ///
48    /// 如果字符串已存在,返回已有索引(不重复存储);
49    /// 否则追加到末尾,返回新索引。
50    /// 同时 `reference_count` +1(每次调用都视为一次引用)。
51    pub fn add(&mut self, s: impl Into<String>) -> usize {
52        let s = s.into();
53        self.reference_count += 1;
54        if let Some(&i) = self.index.get(&s) {
55            return i;
56        }
57        let i = self.strings.len();
58        self.index.insert(s.clone(), i);
59        self.strings.push(s);
60        i
61    }
62
63    /// 仅查询字符串的索引(不增加引用计数)。
64    ///
65    /// 如果字符串已存在,返回 `Some(索引)`;否则返回 `None`。
66    pub fn index_of(&self, s: &str) -> Option<usize> {
67        self.index.get(s).copied()
68    }
69
70    /// 按索引获取字符串。
71    pub fn get(&self, index: usize) -> Option<&str> {
72        self.strings.get(index).map(|s| s.as_str())
73    }
74
75    /// 唯一字符串数(`uniqueCount`)。
76    pub fn unique_count(&self) -> usize {
77        self.strings.len()
78    }
79
80    /// 总引用次数(`count`)。
81    pub fn count(&self) -> usize {
82        self.reference_count
83    }
84
85    /// 是否为空。
86    pub fn is_empty(&self) -> bool {
87        self.strings.is_empty()
88    }
89
90    /// 迭代所有字符串(按索引顺序)。
91    pub fn iter(&self) -> impl Iterator<Item = (usize, &str)> {
92        self.strings
93            .iter()
94            .enumerate()
95            .map(|(i, s)| (i, s.as_str()))
96    }
97
98    /// 序列化为 `xl/sharedStrings.xml` 的 XML 字符串。
99    ///
100    /// # 输出格式
101    ///
102    /// ```xml
103    /// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
104    /// <sst xmlns="..." count="2" uniqueCount="2">
105    ///   <si><t>Hello</t></si>
106    ///   <si><t>World</t></si>
107    /// </sst>
108    /// ```
109    pub fn to_xml(&self) -> String {
110        let mut out = String::with_capacity(256 + self.strings.len() * 32);
111        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n");
112        out.push_str(&format!(
113            "<sst xmlns=\"{}\" count=\"{}\" uniqueCount=\"{}\">",
114            NS_SPREADSHEETML,
115            self.reference_count,
116            self.strings.len()
117        ));
118        for s in &self.strings {
119            out.push_str("<si><t>");
120            out.push_str(&xml_escape_text(s));
121            out.push_str("</t></si>");
122        }
123        out.push_str("</sst>");
124        out
125    }
126
127    /// 从 `xl/sharedStrings.xml` 的 XML 字符串解析。
128    ///
129    /// # 解析策略
130    ///
131    /// SpreadsheetML 的 `<si>` 支持两种文本形态:
132    /// - **plain text**:`<si><t>Hello</t></si>`(单一 `<t>`);
133    /// - **rich text**:`<si><r><rPr>...</rPr><t>He</t></r><r><t>llo</t></r></si>`
134    ///   (多个 `<r>` run,每个 run 携带自己的样式,Phase 2.2 样式系统会用到)。
135    ///
136    /// 本实现采用 **三标志位状态机**(`in_si` / `in_r` / `in_t`),
137    /// 无论 plain 还是 rich,都把 `<t>` 文本累积到同一个 `current`,
138    /// 在 `</si>` 时把 `current` 作为整体字符串插入表。
139    /// 这样既正确处理 plain 文本,也能在 rich text 场景下保留拼接后的纯文本(样式信息丢失,留给 0.2.0)。
140    ///
141    /// # 错误
142    /// - [`Error::Xml`]:XML 格式错误或文本 unescape 失败;
143    /// - [`Error::Schema`]:遇到不符合 SpreadsheetML 规范的元素(保留枚举,当前路径不主动触发)。
144    pub fn from_xml(xml: &str) -> Result<Self> {
145        use quick_xml::events::Event;
146        use quick_xml::reader::Reader;
147
148        let mut sst = SharedStringsTable::new();
149        let mut rd = Reader::from_str(xml);
150        // 不开启 trim_text:quick-xml 0.40 会把 entity(`&lt;` 等)拆成独立的
151        // GeneralRef 事件,trim_text 会修剪每个 Text 片段两端的空白,导致
152        // `Hello <World>` 中 "Hello " 后的空格被修剪为 "Hello",破坏 round-trip。
153        // 我们的 to_xml 输出是紧凑格式(无缩进),数字解析时会手动 trim()。
154        let mut buf = Vec::new();
155        // 三标志位状态机:
156        // - in_si:在 <si> 内(一个字符串条目);
157        // - in_r:在 <si><r> 内(rich text run,Phase 2.2 样式系统支持);
158        // - in_t:在 <t> 内(实际承载文本的元素,plain 或 rich 均用此元素)。
159        let mut in_si = false;
160        let mut in_r = false;
161        let mut in_t = false;
162        let mut current = String::new();
163
164        loop {
165            match rd.read_event_into(&mut buf) {
166                Ok(Event::Start(e)) => match e.name().as_ref() {
167                    b"si" => {
168                        in_si = true;
169                        current.clear();
170                    }
171                    b"r" if in_si => {
172                        in_r = true;
173                    }
174                    b"t" if in_si => {
175                        in_t = true;
176                    }
177                    _ => {}
178                },
179                Ok(Event::End(e)) => match e.name().as_ref() {
180                    b"si" => {
181                        if in_si {
182                            // </si> 收尾:把累积的 current 作为一个字符串插入表。
183                            // 无论是 plain `<t>` 还是多个 `<r><t>` 拼接,current 都是最终纯文本。
184                            sst.strings.push(current.clone());
185                            let idx = sst.strings.len() - 1;
186                            sst.index.insert(current.clone(), idx);
187                            sst.reference_count += 1;
188                            current.clear();
189                        }
190                        in_si = false;
191                    }
192                    b"t" => {
193                        in_t = false;
194                    }
195                    b"r" => {
196                        in_r = false;
197                    }
198                    _ => {}
199                },
200                Ok(Event::Text(t)) if in_si && in_t => {
201                    // 因为 <t> 同时用于 plain 和 rich text,统一累积到 current;
202                    // 多个 <r><t> 顺序拼接得到完整字符串。
203                    // quick-xml 0.40 的 Text 事件只包含纯文本字节,entity(`&lt;` 等)
204                    // 会被拆成独立的 GeneralRef 事件,因此这里无需 unescape。
205                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
206                    current.push_str(text_str);
207                }
208                Ok(Event::GeneralRef(r)) if in_si && in_t => {
209                    // quick-xml 0.40 把 `&lt;` `&gt;` `&amp;` `&#60;` 等 entity reference
210                    // 作为独立的 GeneralRef 事件发出,Text 事件只夹带 entity 之间的纯文本。
211                    // 这里需要手动解析 entity 并追加到 current,否则特殊字符会丢失。
212                    // 先尝试字符引用(`&#60;` / `&#x3C;`),再回退到 5 个预定义命名 entity。
213                    if let Some(ch) = r
214                        .resolve_char_ref()
215                        .map_err(|e| Error::Xml(format!("sharedStrings char ref: {e}")))?
216                    {
217                        current.push(ch);
218                    } else {
219                        let name = r
220                            .decode()
221                            .map_err(|e| Error::Xml(format!("sharedStrings entity decode: {e}")))?;
222                        let ch = match name.as_ref() {
223                            "lt" => '<',
224                            "gt" => '>',
225                            "amp" => '&',
226                            "quot" => '"',
227                            "apos" => '\'',
228                            other => {
229                                return Err(Error::Xml(format!(
230                                    "sharedStrings unknown entity: &{other};"
231                                )))
232                            }
233                        };
234                        current.push(ch);
235                    }
236                }
237                Ok(Event::CData(t)) if in_si && in_t => {
238                    // CDATA 段(罕见但合法):直接 append UTF-8 字节。
239                    if let Ok(s) = std::str::from_utf8(&t) {
240                        current.push_str(s);
241                    }
242                }
243                Ok(Event::Eof) => break,
244                Ok(_) => {}
245                Err(e) => {
246                    return Err(Error::Xml(format!("sharedStrings parse: {e}")));
247                }
248            }
249            buf.clear();
250        }
251        // in_r 仅在状态机内部用于区分上下文,不影响最终结果
252        let _ = in_r;
253        Ok(sst)
254    }
255}
256
257/// XML 文本节点转义(覆盖 `& < >`,文本节点内无需转义引号)。
258///
259/// 注意:与属性转义不同,文本节点内的 `"` / `'` 无需转义。
260fn xml_escape_text(s: &str) -> String {
261    let mut out = String::with_capacity(s.len());
262    for c in s.chars() {
263        match c {
264            '&' => out.push_str("&amp;"),
265            '<' => out.push_str("&lt;"),
266            '>' => out.push_str("&gt;"),
267            _ => out.push(c),
268        }
269    }
270    out
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn add_dedup() {
279        let mut sst = SharedStringsTable::new();
280        let i1 = sst.add("Hello");
281        let i2 = sst.add("World");
282        let i3 = sst.add("Hello"); // 重复,应返回同一索引
283        assert_eq!(i1, 0);
284        assert_eq!(i2, 1);
285        assert_eq!(i3, 0);
286        assert_eq!(sst.unique_count(), 2);
287        assert_eq!(sst.count(), 3); // 3 次引用
288    }
289
290    #[test]
291    fn get_and_iter() {
292        let mut sst = SharedStringsTable::new();
293        sst.add("Hello");
294        sst.add("World");
295        assert_eq!(sst.get(0), Some("Hello"));
296        assert_eq!(sst.get(1), Some("World"));
297        assert_eq!(sst.get(2), None);
298        let all: Vec<(usize, &str)> = sst.iter().collect();
299        assert_eq!(all, vec![(0, "Hello"), (1, "World")]);
300    }
301
302    #[test]
303    fn round_trip() {
304        let mut sst = SharedStringsTable::new();
305        sst.add("Hello");
306        sst.add("World");
307        sst.add("中文测试");
308        let xml = sst.to_xml();
309        let sst2 = SharedStringsTable::from_xml(&xml).unwrap();
310        assert_eq!(sst2.unique_count(), 3);
311        assert_eq!(sst2.get(0), Some("Hello"));
312        assert_eq!(sst2.get(1), Some("World"));
313        assert_eq!(sst2.get(2), Some("中文测试"));
314    }
315
316    #[test]
317    fn xml_escape_special_chars() {
318        let mut sst = SharedStringsTable::new();
319        sst.add("a<b>&c");
320        let xml = sst.to_xml();
321        assert!(xml.contains("a&lt;b&gt;&amp;c"));
322        let sst2 = SharedStringsTable::from_xml(&xml).unwrap();
323        assert_eq!(sst2.get(0), Some("a<b>&c"));
324    }
325
326    #[test]
327    fn empty_table() {
328        let sst = SharedStringsTable::new();
329        assert!(sst.is_empty());
330        assert_eq!(sst.unique_count(), 0);
331        let xml = sst.to_xml();
332        assert!(xml.contains("count=\"0\""));
333        assert!(xml.contains("uniqueCount=\"0\""));
334    }
335}