Skip to main content

docparser_office/
xlsx.rs

1//! XLSX 解析:ZIP + quick-xml 流式文本提取。
2//!
3//! 架构:`XlsxRawBackend` -> `ParseCapability`,支持共享字符串表、样式格式化、日期格式。
4
5use chrono::NaiveDate;
6use docparser_core::error::{DocError, Result};
7use docparser_core::plugin::{ParseCapability, RawDoc, RawPage};
8use quick_xml::events::Event;
9use quick_xml::Reader;
10
11use crate::zip_read_str;
12
13/// XLSX raw 后端。
14pub struct XlsxRawBackend;
15
16impl ParseCapability for XlsxRawBackend {
17    fn id(&self) -> &str {
18        "office-raw"
19    }
20    fn formats(&self) -> &[&str] {
21        &["xlsx"]
22    }
23    fn parse(&self, path: &str) -> Result<RawDoc> {
24        let file = std::fs::File::open(path)
25            .map_err(|e| DocError::corrupt(format!("cannot open {path}: {e}")))?;
26        let mut zip = zip::ZipArchive::new(file)
27            .map_err(|e| DocError::corrupt(format!("ZIP open failed: {e}")))?;
28
29        // 读取 workbook.xml 获取 sheet 列表
30        let wb_xml = zip_read_str(&mut zip, "xl/workbook.xml")?
31            .ok_or_else(|| DocError::corrupt("missing xl/workbook.xml"))?;
32        let sheet_names = parse_sheet_names(&wb_xml)?;
33
34        // 读取 SharedStrings(惰性)
35        let sst = SharedStrings::new(&mut zip)?;
36
37        // 读取 Styles(惰性)
38        let styles = Styles::new(&mut zip)?;
39
40        // 解析每个 sheet
41        let mut pages = Vec::new();
42        for (idx, (_rid, name)) in sheet_names.iter().enumerate() {
43            // 读取 sheet 的 XML
44            let sheet_path = format!("xl/worksheets/sheet{}.xml", idx + 1);
45            let xml = zip_read_str(&mut zip, &sheet_path)?.unwrap_or_default();
46            let rows = parse_sheet_rows(&xml, &sst, &styles)?;
47            let text = rows
48                .iter()
49                .map(|r| r.join(","))
50                .collect::<Vec<_>>()
51                .join("\n");
52            pages.push(RawPage {
53                name: Some(name.clone()),
54                items: vec![docparser_core::plugin::RawTextItem { text, bbox: None }],
55                ..Default::default()
56            });
57        }
58
59        Ok(RawDoc {
60            pages,
61            ..Default::default()
62        })
63    }
64}
65
66/// 共享字符串表。
67struct SharedStrings {
68    items: Vec<String>,
69}
70
71impl SharedStrings {
72    fn new(zip: &mut zip::ZipArchive<std::fs::File>) -> Result<Self> {
73        let xml = match zip_read_str(zip, "xl/sharedStrings.xml")? {
74            Some(s) => s,
75            None => {
76                return Ok(SharedStrings { items: Vec::new() });
77            }
78        };
79        let items = parse_sst_items(&xml)?;
80        Ok(SharedStrings { items })
81    }
82
83    fn get(&self, idx: usize) -> Option<&str> {
84        self.items.get(idx).map(|s| s.as_str())
85    }
86}
87
88/// 解析 `<sst>` 中的 `<si>` 条目。
89fn parse_sst_items(xml: &str) -> Result<Vec<String>> {
90    let mut reader = Reader::from_str(xml);
91    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
92    let mut buf = Vec::new();
93    let mut items = Vec::new();
94    let mut in_t = false;
95    let mut text = String::new();
96
97    loop {
98        match reader.read_event_into(&mut buf) {
99            Ok(Event::Start(ref e)) => match e.name().local_name().as_ref() {
100                crate::tags::SI => {
101                    text.clear();
102                }
103                crate::tags::T => {
104                    in_t = true;
105                }
106                _ => {}
107            },
108            Ok(Event::End(ref e)) => match e.name().local_name().as_ref() {
109                crate::tags::SI => {
110                    items.push(text.trim().to_string());
111                    text.clear();
112                }
113                crate::tags::T => {
114                    in_t = false;
115                }
116                crate::tags::SST => {
117                    break;
118                }
119                _ => {}
120            },
121            Ok(Event::Text(ref e)) => {
122                if in_t {
123                    // quick-xml 0.38+:Text 已是解码后的字符串,实体需显式 unescape
124                    let raw = e.xml10_content();
125                    if let Ok(t) = quick_xml::escape::unescape(&raw) {
126                        text.push_str(&t);
127                    }
128                }
129            }
130            Ok(Event::GeneralRef(ref e)) => {
131                if in_t {
132                    crate::push_general_ref(&mut text, e);
133                }
134            }
135            Ok(Event::CData(ref e)) => {
136                if in_t {
137                    text.push_str(e.as_ref());
138                }
139            }
140            Ok(Event::Eof) => break,
141            Err(e) => return Err(DocError::corrupt(format!("SST parse error: {e}"))),
142            _ => {}
143        }
144        buf.clear();
145    }
146    Ok(items)
147}
148
149/// 单元格样式元数据。
150#[derive(Default)]
151struct Styles {
152    num_fmts: std::collections::HashMap<usize, String>,
153    xf_num_fmt_ids: Vec<usize>,
154}
155
156impl Styles {
157    fn new(zip: &mut zip::ZipArchive<std::fs::File>) -> Result<Self> {
158        let xml = match zip_read_str(zip, "xl/styles.xml")? {
159            Some(s) => s,
160            None => return Ok(Styles::default()),
161        };
162        let mut s = Styles::default();
163        s.parse(&xml)?;
164        Ok(s)
165    }
166
167    fn parse(&mut self, xml: &str) -> Result<()> {
168        let mut reader = Reader::from_str(xml);
169        // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
170        let mut buf = Vec::new();
171        let mut _in_num_fmts = false;
172        let mut _in_cell_xfs = false;
173        let mut _num_fmt_id_val: Option<usize> = None;
174
175        loop {
176            match reader.read_event_into(&mut buf) {
177                Ok(Event::Start(ref e)) => match e.name().local_name().as_ref() {
178                    crate::tags::NUM_FMT => {
179                        _num_fmt_id_val = None;
180                        let mut fmt_id = 0usize;
181                        let mut fmt_code = String::new();
182                        for attr in e.attributes().filter_map(|a| a.ok()) {
183                            match attr.key.as_ref() {
184                                "numFmtId" => fmt_id = attr.value.parse().unwrap_or(0),
185                                "formatCode" => fmt_code = attr.value.to_string(),
186                                _ => {}
187                            }
188                        }
189                        if fmt_id >= 164 {
190                            self.num_fmts.insert(fmt_id, fmt_code);
191                        }
192                    }
193                    crate::tags::XF => {
194                        let mut num_fmt_id = 0usize;
195                        for attr in e.attributes().filter_map(|a| a.ok()) {
196                            if attr.key.as_ref() == "numFmtId" {
197                                num_fmt_id = attr.value.parse().unwrap_or(0);
198                            }
199                        }
200                        self.xf_num_fmt_ids.push(num_fmt_id);
201                    }
202                    _ => {}
203                },
204                Ok(Event::Empty(ref e)) => match e.name().local_name().as_ref() {
205                    crate::tags::NUM_FMT => {
206                        let mut fmt_id = 0usize;
207                        let mut fmt_code = String::new();
208                        for attr in e.attributes().filter_map(|a| a.ok()) {
209                            match attr.key.as_ref() {
210                                "numFmtId" => fmt_id = attr.value.parse().unwrap_or(0),
211                                "formatCode" => fmt_code = attr.value.to_string(),
212                                _ => {}
213                            }
214                        }
215                        if fmt_id >= 164 {
216                            self.num_fmts.insert(fmt_id, fmt_code);
217                        }
218                    }
219                    crate::tags::XF => {
220                        let mut num_fmt_id = 0usize;
221                        for attr in e.attributes().filter_map(|a| a.ok()) {
222                            if attr.key.as_ref() == "numFmtId" {
223                                num_fmt_id = attr.value.parse().unwrap_or(0);
224                            }
225                        }
226                        self.xf_num_fmt_ids.push(num_fmt_id);
227                    }
228                    _ => {}
229                },
230                Ok(Event::End(ref e)) => {
231                    if e.name().local_name().as_ref() == crate::tags::CELL_XFS {
232                        _in_cell_xfs = false;
233                    }
234                }
235                Ok(Event::Eof) => break,
236                Err(e) => return Err(DocError::corrupt(format!("Styles parse error: {e}"))),
237                _ => {}
238            }
239            buf.clear();
240        }
241        Ok(())
242    }
243
244    /// 判断指定 xf 索引是否为日期格式
245    fn is_date_format(&self, xf_idx: Option<usize>) -> bool {
246        let num_fmt_id = xf_idx
247            .and_then(|i| self.xf_num_fmt_ids.get(i))
248            .copied()
249            .unwrap_or(0);
250
251        // 内置日期格式 ID: 14-22, 27-36, 45-47, 50-58, 71-81
252        matches!(num_fmt_id,
253            14..=22 | 27..=36 | 45..=47 | 50..=58 | 71..=81
254        ) || self.num_fmts.contains_key(&num_fmt_id)
255    }
256
257    /// 获取指定 xf 索引的格式代码字符串(返回 OOXML 格式代码如 "yyyy-mm-dd")。
258    fn code_for(&self, xf_idx: Option<usize>) -> Option<&str> {
259        let num_fmt_id = xf_idx
260            .and_then(|i| self.xf_num_fmt_ids.get(i))
261            .copied()
262            .unwrap_or(0);
263        if num_fmt_id == 0 {
264            return None;
265        }
266        if let Some(code) = self.num_fmts.get(&num_fmt_id) {
267            return Some(code.as_str());
268        }
269        BUILTIN_FORMATS
270            .iter()
271            .find(|(id, _)| *id == num_fmt_id)
272            .map(|(_, code)| *code)
273    }
274}
275
276/// OOXML 内置日期格式映射(与 Python ooxml.py BUILTIN_FORMATS 对齐)。
277const BUILTIN_FORMATS: &[(usize, &str)] = &[
278    (14, "mm-dd-yy"),
279    (15, "d-mmm-yy"),
280    (16, "d-mmm"),
281    (17, "mmm-yy"),
282    (18, "h:mm AM/PM"),
283    (19, "h:mm:ss AM/PM"),
284    (20, "h:mm"),
285    (21, "h:mm:ss"),
286    (22, "m/d/yy h:mm"),
287    (45, "mm:ss"),
288    (46, "[h]:mm:ss"),
289    (47, "mmss.0"),
290];
291
292/// OLE Automation Date 序列号转 NaiveDate。Epoch = Dec 30, 1899。
293fn ole_serial_to_date(serial: f64) -> Option<NaiveDate> {
294    let epoch = NaiveDate::from_ymd_opt(1899, 12, 30)?;
295    let mut days = serial as i64;
296    // Lotus 123 bug:serial 60 (Feb 29, 1900) 不存在
297    if serial > 0.0 && serial < 60.0 {
298        days += 1;
299    }
300    epoch.checked_add_signed(chrono::Duration::days(days))
301}
302
303/// 将 OOXML 日期格式代码转换为 strftime 格式。
304fn fmt_to_strftime(fmt: &str) -> String {
305    let is_12h = fmt.contains("AM/PM");
306    let s = fmt
307        .replace("yyyy", "%Y")
308        .replace("yy", "%y")
309        .replace("mmm", "%b")
310        .replace("dd", "%d");
311    // 区分分钟 mm 与月份 mm(用占位符避免二次替换,与 Python ooxml.py 一致)
312    let s = s.replace(":mm", ":M_M").replace("mm:", "M_M:");
313    let s = s
314        .replace("mm", "=*=")
315        .replace("m", "=*=")
316        .replace("=*=", "%m");
317    let s = s.replace("M_M", "%M");
318    let s = s.replace("ss", "%S").replace("AM/PM", "%p");
319    if is_12h {
320        s.replace("h", "%I")
321    } else {
322        s.replace("h", "%H")
323    }
324}
325
326/// 格式化 Excel 日期(OLE 序列号 + 格式代码 → 字符串)。
327fn format_excel_date(serial: f64, format_code: &str) -> String {
328    if let Some(date) = ole_serial_to_date(serial) {
329        let strfmt = fmt_to_strftime(format_code);
330        date.format(&strfmt).to_string()
331    } else {
332        format!("num:{}", serial)
333    }
334}
335
336/// 格式化数字:去科学记数法、去前导零,与 Python 的 `str()` 一致。
337fn format_number(raw: &str) -> String {
338    let trimmed = raw.trim();
339    if trimmed.is_empty() {
340        return String::new();
341    }
342    if let Ok(num) = trimmed.parse::<f64>() {
343        if num.fract() == 0.0 && num.is_finite() {
344            format!("{}", num as i64)
345        } else {
346            format!("{}", num)
347        }
348    } else {
349        trimmed.to_string()
350    }
351}
352
353/// 解析 workbook.xml 获取 sheet 名称列表。
354fn parse_sheet_names(xml: &str) -> Result<Vec<(String, String)>> {
355    let mut reader = Reader::from_str(xml);
356    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
357    let mut buf = Vec::new();
358    let mut sheets = Vec::new();
359
360    loop {
361        match reader.read_event_into(&mut buf) {
362            Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
363                if e.name().local_name().as_ref() == "sheet" {
364                    let mut rid = String::new();
365                    let mut name = String::new();
366                    for attr in e.attributes().filter_map(|a| a.ok()) {
367                        // 属性按完整 QName 比较(`r:id` 与裸 `id` 区分,与迁移前一致)
368                        match attr.key.as_ref() {
369                            "r:id" => rid = attr.value.to_string(),
370                            "name" => name = attr.value.to_string(),
371                            // 某些 xlsx 使用短 id 属性
372                            "id" if rid.is_empty() => rid = attr.value.to_string(),
373                            _ => {}
374                        }
375                    }
376                    if !name.is_empty() {
377                        sheets.push((rid, name));
378                    }
379                }
380            }
381            Ok(Event::End(ref e)) => {
382                if e.name().local_name().as_ref() == "workbook" {
383                    break;
384                }
385            }
386            Ok(Event::Eof) => break,
387            Err(e) => return Err(DocError::corrupt(format!("workbook parse error: {e}"))),
388            _ => {}
389        }
390        buf.clear();
391    }
392    Ok(sheets)
393}
394
395/// 解析 sheet 中的行/列单元格。
396fn parse_sheet_rows(xml: &str, sst: &SharedStrings, styles: &Styles) -> Result<Vec<Vec<String>>> {
397    let mut reader = Reader::from_str(xml);
398    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
399    let mut buf = Vec::new();
400    let mut rows: Vec<Vec<String>> = Vec::new();
401    let mut current_row: Vec<(usize, String)> = Vec::new(); // (col_idx, text)
402    let mut cell_ref = String::new();
403    let mut cell_type = 's'; // s = shared string, n = number, b = boolean, e = error, str = inline
404    let mut cell_style: Option<usize> = None;
405    let mut cell_value = String::new();
406    let mut in_v = false;
407    let mut in_is = false;
408    let mut in_is_t = false;
409
410    // regex 用于解析列号:如 "A1" -> 0, "Z1" -> 25, "AA1" -> 26
411    fn col_from_ref(r: &str) -> usize {
412        let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
413        let mut col = 0usize;
414        for c in letters.chars() {
415            col = col * 26 + (c.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
416        }
417        if col > 0 {
418            col - 1
419        } else {
420            0
421        }
422    }
423
424    loop {
425        match reader.read_event_into(&mut buf) {
426            Ok(Event::Start(ref e)) => {
427                match e.name().local_name().as_ref() {
428                    crate::tags::C => {
429                        cell_type = 's';
430                        cell_style = None;
431                        cell_value.clear();
432                        cell_ref.clear();
433                        for attr in e.attributes().filter_map(|a| a.ok()) {
434                            match attr.key.as_ref() {
435                                "r" => cell_ref = attr.value.to_string(),
436                                "t" => {
437                                    // t: s=shared string, n=number, b=boolean, e=error, str=inlineStr
438                                    cell_type = match attr.value.as_ref() {
439                                        "s" => 's',                 // shared string
440                                        "n" => 'n',                 // number
441                                        "b" => 'b',                 // boolean
442                                        "e" => 'e',                 // error
443                                        "str" | "inlineStr" => 'i', // inline string
444                                        _ => 's',
445                                    };
446                                }
447                                "s" => cell_style = attr.value.parse().ok(),
448                                _ => {}
449                            }
450                        }
451                    }
452                    crate::tags::V => {
453                        in_v = true;
454                    }
455                    crate::tags::IS => {
456                        in_is = true;
457                    }
458                    crate::tags::T if in_is => in_is_t = true,
459                    _ => {}
460                }
461            }
462            Ok(Event::End(ref e)) => {
463                match e.name().local_name().as_ref() {
464                    crate::tags::C => {
465                        // 处理单元格值
466                        let text = match cell_type {
467                            's' => {
468                                let idx: usize = cell_value.trim().parse().unwrap_or(0);
469                                sst.get(idx).unwrap_or("").to_string()
470                            }
471                            'i' => {
472                                // inline string: <is><t>...</t></is>,文本已在 cell_value 中
473                                cell_value.trim().to_string()
474                            }
475                            'b' => {
476                                // 匹配 Python: True/False 而非 TRUE/FALSE
477                                if cell_value.trim() == "1" {
478                                    "True".to_string()
479                                } else {
480                                    "False".to_string()
481                                }
482                            }
483                            'e' => {
484                                // 匹配 Python: 前缀 "ERROR:"
485                                format!("ERROR:{}", cell_value.trim())
486                            }
487                            // 'n' or default: 纯数字或日期
488                            _ => {
489                                let val = cell_value.trim();
490                                if val.is_empty() {
491                                    String::new()
492                                } else if styles.is_date_format(cell_style) {
493                                    // 日期:按格式代码格式化
494                                    if let Ok(num) = val.parse::<f64>() {
495                                        if let Some(code) = styles.code_for(cell_style) {
496                                            format_excel_date(num, code)
497                                        } else {
498                                            format!("num:{}", val)
499                                        }
500                                    } else {
501                                        format!("num:{}", val)
502                                    }
503                                } else {
504                                    // 数值:去科学记数法、去前导零
505                                    format_number(val)
506                                }
507                            }
508                        };
509
510                        let col = col_from_ref(&cell_ref);
511                        current_row.push((col, text));
512                        in_v = false;
513                        in_is = false;
514                        in_is_t = false;
515                    }
516                    crate::tags::ROW => {
517                        // 排序并输出行
518                        current_row.sort_by_key(|(col, _)| *col);
519                        let row_str: Vec<String> =
520                            current_row.iter().map(|(_, t)| t.clone()).collect();
521                        rows.push(row_str);
522                        current_row.clear();
523                    }
524                    crate::tags::V => {
525                        in_v = false;
526                    }
527                    crate::tags::IS => {
528                        in_is = false;
529                    }
530                    crate::tags::T if in_is_t => in_is_t = false,
531                    _ => {}
532                }
533            }
534            Ok(Event::Text(ref e)) => {
535                if in_v || in_is_t {
536                    let raw = e.xml10_content();
537                    if let Ok(t) = quick_xml::escape::unescape(&raw) {
538                        cell_value.push_str(&t);
539                    }
540                }
541            }
542            Ok(Event::GeneralRef(ref e)) => {
543                if in_v || in_is_t {
544                    crate::push_general_ref(&mut cell_value, e);
545                }
546            }
547            Ok(Event::CData(ref e)) => {
548                if in_v || in_is_t {
549                    cell_value.push_str(e.as_ref());
550                }
551            }
552            Ok(Event::Eof) => break,
553            Err(e) => return Err(DocError::corrupt(format!("sheet parse error: {e}"))),
554            _ => {}
555        }
556        buf.clear();
557    }
558    Ok(rows)
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    #[test]
566    fn test_sst_empty() {
567        let sst = SharedStrings { items: vec![] };
568        assert_eq!(sst.get(0), None);
569    }
570
571    #[test]
572    fn test_sst_parse() {
573        let xml = r#"<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="2">
574          <si><t>Hello</t></si>
575          <si><t>World</t></si>
576        </sst>"#;
577        let items = parse_sst_items(xml).unwrap();
578        assert_eq!(items.len(), 2);
579        assert_eq!(items[0], "Hello");
580        assert_eq!(items[1], "World");
581    }
582
583    #[test]
584    fn test_sheet_names() {
585        let xml = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
586          xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
587          <sheets>
588            <sheet name="Sheet1" r:id="rId1"/>
589            <sheet name="Sheet2" r:id="rId2"/>
590          </sheets>
591        </workbook>"#;
592        let sheets = parse_sheet_names(xml).unwrap();
593        assert_eq!(sheets.len(), 2);
594        assert_eq!(sheets[0].1, "Sheet1");
595    }
596
597    /// reader 迁移 smoke:最小 xlsx(workbook + sharedStrings + sheet),走完整 ZIP → 事件流路径。
598    ///
599    /// 覆盖 `local_name()` 元素匹配、`attr.value`(Cow<str>)取值与共享字符串实体解码。
600    #[test]
601    fn smoke_parse_minimal_xlsx_zip() {
602        use std::io::Write;
603
604        const WORKBOOK: &str = concat!(
605            r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" "#,
606            r#"xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
607            r#"<sheets><sheet name="S1" sheetId="1" r:id="rId1"/></sheets></workbook>"#
608        );
609        const SST: &str = concat!(
610            r#"<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">"#,
611            r#"<si><t>甲</t></si><si><t>乙 &amp; 丙</t></si></sst>"#
612        );
613        const SHEET: &str = concat!(
614            r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">"#,
615            r#"<sheetData><row r="1">"#,
616            r#"<c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c>"#,
617            r#"</row></sheetData></worksheet>"#
618        );
619
620        let dir = std::env::temp_dir().join("docparser_office_xlsx_smoke");
621        std::fs::create_dir_all(&dir).unwrap();
622        let path = dir.join("min.xlsx");
623        {
624            let f = std::fs::File::create(&path).unwrap();
625            let mut zw = zip::ZipWriter::new(f);
626            let opts = zip::write::SimpleFileOptions::default();
627            for (name, body) in [
628                ("xl/workbook.xml", WORKBOOK),
629                ("xl/sharedStrings.xml", SST),
630                ("xl/worksheets/sheet1.xml", SHEET),
631            ] {
632                zw.start_file(name, opts).unwrap();
633                zw.write_all(body.as_bytes()).unwrap();
634            }
635            zw.finish().unwrap();
636        }
637
638        let raw = XlsxRawBackend.parse(path.to_str().unwrap()).unwrap();
639        std::fs::remove_file(&path).ok();
640
641        assert_eq!(raw.pages.len(), 1);
642        assert_eq!(raw.pages[0].name.as_deref(), Some("S1"));
643        assert_eq!(raw.pages[0].items.len(), 1);
644        assert_eq!(raw.pages[0].items[0].text, "甲,乙 & 丙");
645    }
646
647    #[test]
648    fn test_col_ref() {
649        fn col(r: &str) -> usize {
650            let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
651            let mut col = 0usize;
652            for c in letters.chars() {
653                col = col * 26 + (c.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
654            }
655            if col > 0 {
656                col - 1
657            } else {
658                0
659            }
660        }
661        assert_eq!(col("A1"), 0);
662        assert_eq!(col("Z1"), 25);
663        assert_eq!(col("AA1"), 26);
664        assert_eq!(col("AB1"), 27);
665    }
666}