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(`<` 等)拆成独立的
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(`<` 等)
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 把 `<` `>` `&` `<` 等 entity reference
210 // 作为独立的 GeneralRef 事件发出,Text 事件只夹带 entity 之间的纯文本。
211 // 这里需要手动解析 entity 并追加到 current,否则特殊字符会丢失。
212 // 先尝试字符引用(`<` / `<`),再回退到 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("&"),
265 '<' => out.push_str("<"),
266 '>' => out.push_str(">"),
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<b>&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}