xlsx-rs2 0.1.0

Rust 版本的 Excel .xlsx 读写库,基于 ooxml-core,对标 python-openpyxl
Documentation
//! SharedStrings Table:`.xlsx` 内的共享字符串表。
//!
//! 对应 OOXML SpreadsheetML 的 `xl/sharedStrings.xml` part,
//! 根元素为 `<sst>`(CT_SharedStrings)。
//!
//! # 设计要点
//!
//! Excel 把所有文本字符串集中存放在 `sharedStrings.xml` 中,
//! 单元格(`<c t="s"><v>i</v></c>`)通过索引引用。
//! 这样可以大幅减小文件体积(重复字符串只存一份)。
//!
//! - `count`:总引用次数(所有 cell 引用的总和);
//! - `uniqueCount`:唯一字符串数(`<si>` 元素个数)。
//!
//! 本实现用 `Vec<String>` 存储字符串,`HashMap<String, usize>` 做反向索引,
//! `add()` 时自动去重(同一字符串返回同一索引)。

use std::collections::HashMap;

use crate::error::{Error, Result};

/// SpreadsheetML 命名空间。
const NS_SPREADSHEETML: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";

/// 共享字符串表。
///
/// 对应 `xl/sharedStrings.xml` 的 `<sst>` 根元素。
#[derive(Debug, Clone, Default)]
pub struct SharedStringsTable {
    /// 字符串列表(按插入顺序,索引即位置)。
    strings: Vec<String>,
    /// 反向索引:字符串内容 → 在 `strings` 中的索引。
    /// 用于 `add()` 时去重,避免相同字符串多次存储。
    index: HashMap<String, usize>,
    /// 总引用次数(所有 cell 引用的总和,用于写出 `count` 属性)。
    /// 注意:`uniqueCount = strings.len()`。
    reference_count: usize,
}

impl SharedStringsTable {
    /// 构造空表。
    pub fn new() -> Self {
        Self::default()
    }

    /// 添加一个字符串,返回其索引。
    ///
    /// 如果字符串已存在,返回已有索引(不重复存储);
    /// 否则追加到末尾,返回新索引。
    /// 同时 `reference_count` +1(每次调用都视为一次引用)。
    pub fn add(&mut self, s: impl Into<String>) -> usize {
        let s = s.into();
        self.reference_count += 1;
        if let Some(&i) = self.index.get(&s) {
            return i;
        }
        let i = self.strings.len();
        self.index.insert(s.clone(), i);
        self.strings.push(s);
        i
    }

    /// 仅查询字符串的索引(不增加引用计数)。
    ///
    /// 如果字符串已存在,返回 `Some(索引)`;否则返回 `None`。
    pub fn index_of(&self, s: &str) -> Option<usize> {
        self.index.get(s).copied()
    }

    /// 按索引获取字符串。
    pub fn get(&self, index: usize) -> Option<&str> {
        self.strings.get(index).map(|s| s.as_str())
    }

    /// 唯一字符串数(`uniqueCount`)。
    pub fn unique_count(&self) -> usize {
        self.strings.len()
    }

    /// 总引用次数(`count`)。
    pub fn count(&self) -> usize {
        self.reference_count
    }

    /// 是否为空。
    pub fn is_empty(&self) -> bool {
        self.strings.is_empty()
    }

    /// 迭代所有字符串(按索引顺序)。
    pub fn iter(&self) -> impl Iterator<Item = (usize, &str)> {
        self.strings
            .iter()
            .enumerate()
            .map(|(i, s)| (i, s.as_str()))
    }

    /// 序列化为 `xl/sharedStrings.xml` 的 XML 字符串。
    ///
    /// # 输出格式
    ///
    /// ```xml
    /// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    /// <sst xmlns="..." count="2" uniqueCount="2">
    ///   <si><t>Hello</t></si>
    ///   <si><t>World</t></si>
    /// </sst>
    /// ```
    pub fn to_xml(&self) -> String {
        let mut out = String::with_capacity(256 + self.strings.len() * 32);
        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n");
        out.push_str(&format!(
            "<sst xmlns=\"{}\" count=\"{}\" uniqueCount=\"{}\">",
            NS_SPREADSHEETML,
            self.reference_count,
            self.strings.len()
        ));
        for s in &self.strings {
            out.push_str("<si><t>");
            out.push_str(&xml_escape_text(s));
            out.push_str("</t></si>");
        }
        out.push_str("</sst>");
        out
    }

    /// 从 `xl/sharedStrings.xml` 的 XML 字符串解析。
    ///
    /// # 解析策略
    ///
    /// SpreadsheetML 的 `<si>` 支持两种文本形态:
    /// - **plain text**:`<si><t>Hello</t></si>`(单一 `<t>`);
    /// - **rich text**:`<si><r><rPr>...</rPr><t>He</t></r><r><t>llo</t></r></si>`
    ///   (多个 `<r>` run,每个 run 携带自己的样式,Phase 2.2 样式系统会用到)。
    ///
    /// 本实现采用 **三标志位状态机**(`in_si` / `in_r` / `in_t`),
    /// 无论 plain 还是 rich,都把 `<t>` 文本累积到同一个 `current`,
    /// 在 `</si>` 时把 `current` 作为整体字符串插入表。
    /// 这样既正确处理 plain 文本,也能在 rich text 场景下保留拼接后的纯文本(样式信息丢失,留给 0.2.0)。
    ///
    /// # 错误
    /// - [`Error::Xml`]:XML 格式错误或文本 unescape 失败;
    /// - [`Error::Schema`]:遇到不符合 SpreadsheetML 规范的元素(保留枚举,当前路径不主动触发)。
    pub fn from_xml(xml: &str) -> Result<Self> {
        use quick_xml::events::Event;
        use quick_xml::reader::Reader;

        let mut sst = SharedStringsTable::new();
        let mut rd = Reader::from_str(xml);
        // 不开启 trim_text:quick-xml 0.40 会把 entity(`&lt;` 等)拆成独立的
        // GeneralRef 事件,trim_text 会修剪每个 Text 片段两端的空白,导致
        // `Hello <World>` 中 "Hello " 后的空格被修剪为 "Hello",破坏 round-trip。
        // 我们的 to_xml 输出是紧凑格式(无缩进),数字解析时会手动 trim()。
        let mut buf = Vec::new();
        // 三标志位状态机:
        // - in_si:在 <si> 内(一个字符串条目);
        // - in_r:在 <si><r> 内(rich text run,Phase 2.2 样式系统支持);
        // - in_t:在 <t> 内(实际承载文本的元素,plain 或 rich 均用此元素)。
        let mut in_si = false;
        let mut in_r = false;
        let mut in_t = false;
        let mut current = String::new();

        loop {
            match rd.read_event_into(&mut buf) {
                Ok(Event::Start(e)) => match e.name().as_ref() {
                    b"si" => {
                        in_si = true;
                        current.clear();
                    }
                    b"r" if in_si => {
                        in_r = true;
                    }
                    b"t" if in_si => {
                        in_t = true;
                    }
                    _ => {}
                },
                Ok(Event::End(e)) => match e.name().as_ref() {
                    b"si" => {
                        if in_si {
                            // </si> 收尾:把累积的 current 作为一个字符串插入表。
                            // 无论是 plain `<t>` 还是多个 `<r><t>` 拼接,current 都是最终纯文本。
                            sst.strings.push(current.clone());
                            let idx = sst.strings.len() - 1;
                            sst.index.insert(current.clone(), idx);
                            sst.reference_count += 1;
                            current.clear();
                        }
                        in_si = false;
                    }
                    b"t" => {
                        in_t = false;
                    }
                    b"r" => {
                        in_r = false;
                    }
                    _ => {}
                },
                Ok(Event::Text(t)) if in_si && in_t => {
                    // 因为 <t> 同时用于 plain 和 rich text,统一累积到 current;
                    // 多个 <r><t> 顺序拼接得到完整字符串。
                    // quick-xml 0.40 的 Text 事件只包含纯文本字节,entity(`&lt;` 等)
                    // 会被拆成独立的 GeneralRef 事件,因此这里无需 unescape。
                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
                    current.push_str(text_str);
                }
                Ok(Event::GeneralRef(r)) if in_si && in_t => {
                    // quick-xml 0.40 把 `&lt;` `&gt;` `&amp;` `&#60;` 等 entity reference
                    // 作为独立的 GeneralRef 事件发出,Text 事件只夹带 entity 之间的纯文本。
                    // 这里需要手动解析 entity 并追加到 current,否则特殊字符会丢失。
                    // 先尝试字符引用(`&#60;` / `&#x3C;`),再回退到 5 个预定义命名 entity。
                    if let Some(ch) = r
                        .resolve_char_ref()
                        .map_err(|e| Error::Xml(format!("sharedStrings char ref: {e}")))?
                    {
                        current.push(ch);
                    } else {
                        let name = r
                            .decode()
                            .map_err(|e| Error::Xml(format!("sharedStrings entity decode: {e}")))?;
                        let ch = match name.as_ref() {
                            "lt" => '<',
                            "gt" => '>',
                            "amp" => '&',
                            "quot" => '"',
                            "apos" => '\'',
                            other => {
                                return Err(Error::Xml(format!(
                                    "sharedStrings unknown entity: &{other};"
                                )))
                            }
                        };
                        current.push(ch);
                    }
                }
                Ok(Event::CData(t)) if in_si && in_t => {
                    // CDATA 段(罕见但合法):直接 append UTF-8 字节。
                    if let Ok(s) = std::str::from_utf8(&t) {
                        current.push_str(s);
                    }
                }
                Ok(Event::Eof) => break,
                Ok(_) => {}
                Err(e) => {
                    return Err(Error::Xml(format!("sharedStrings parse: {e}")));
                }
            }
            buf.clear();
        }
        // in_r 仅在状态机内部用于区分上下文,不影响最终结果
        let _ = in_r;
        Ok(sst)
    }
}

/// XML 文本节点转义(覆盖 `& < >`,文本节点内无需转义引号)。
///
/// 注意:与属性转义不同,文本节点内的 `"` / `'` 无需转义。
fn xml_escape_text(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_dedup() {
        let mut sst = SharedStringsTable::new();
        let i1 = sst.add("Hello");
        let i2 = sst.add("World");
        let i3 = sst.add("Hello"); // 重复,应返回同一索引
        assert_eq!(i1, 0);
        assert_eq!(i2, 1);
        assert_eq!(i3, 0);
        assert_eq!(sst.unique_count(), 2);
        assert_eq!(sst.count(), 3); // 3 次引用
    }

    #[test]
    fn get_and_iter() {
        let mut sst = SharedStringsTable::new();
        sst.add("Hello");
        sst.add("World");
        assert_eq!(sst.get(0), Some("Hello"));
        assert_eq!(sst.get(1), Some("World"));
        assert_eq!(sst.get(2), None);
        let all: Vec<(usize, &str)> = sst.iter().collect();
        assert_eq!(all, vec![(0, "Hello"), (1, "World")]);
    }

    #[test]
    fn round_trip() {
        let mut sst = SharedStringsTable::new();
        sst.add("Hello");
        sst.add("World");
        sst.add("中文测试");
        let xml = sst.to_xml();
        let sst2 = SharedStringsTable::from_xml(&xml).unwrap();
        assert_eq!(sst2.unique_count(), 3);
        assert_eq!(sst2.get(0), Some("Hello"));
        assert_eq!(sst2.get(1), Some("World"));
        assert_eq!(sst2.get(2), Some("中文测试"));
    }

    #[test]
    fn xml_escape_special_chars() {
        let mut sst = SharedStringsTable::new();
        sst.add("a<b>&c");
        let xml = sst.to_xml();
        assert!(xml.contains("a&lt;b&gt;&amp;c"));
        let sst2 = SharedStringsTable::from_xml(&xml).unwrap();
        assert_eq!(sst2.get(0), Some("a<b>&c"));
    }

    #[test]
    fn empty_table() {
        let sst = SharedStringsTable::new();
        assert!(sst.is_empty());
        assert_eq!(sst.unique_count(), 0);
        let xml = sst.to_xml();
        assert!(xml.contains("count=\"0\""));
        assert!(xml.contains("uniqueCount=\"0\""));
    }
}