1use std::collections::HashSet;
42
43use super::cursor::{
44 attr, attr_full, attributes_full, skip_element, unescape_entities, Cursor, Event,
45};
46use super::ooxml::{not_a_workbook, step, xml_refused};
47use super::{
48 emit_formula, NameInfo, Row, SheetInfo, SheetStats, Sink, Source, Summary, TableInfo, Value,
49 Visibility,
50};
51use crate::intrinsics::fold;
52use crate::messages::Refusal;
53use crate::refers;
54use crate::sheet::merge::part_text_raw;
55use crate::sheet::zip;
56use crate::sheet::{cell_ref, MAX_COLUMN, MAX_ROW};
57
58const MIMETYPE: &str = "application/vnd.oasis.opendocument.spreadsheet";
59const CONTENT: &str = "content.xml";
60
61pub fn is_opendocument(bytes: &[u8]) -> bool {
64 let Some(entries) = zip::entries(bytes) else {
65 return false;
66 };
67 if let Some(e) = entries.iter().find(|e| e.name == "mimetype") {
68 if let Some(data) = zip::stored_data(bytes, e) {
69 return data.starts_with(b"application/vnd.oasis.opendocument");
70 }
71 return matches!(
72 part_text_raw(bytes, &entries, "mimetype"),
73 Ok(Some(ref t)) if t.starts_with("application/vnd.oasis.opendocument")
74 );
75 }
76 let has = |n: &str| entries.iter().any(|e| e.name == n);
77 has(CONTENT) && !has("xl/workbook.xml")
78}
79
80pub struct Document {
82 label: String,
83 text: String,
85 sheets: Vec<SheetInfo>,
86 spans: Vec<(usize, usize)>,
88 names: Vec<NameInfo>,
89 tables: Vec<TableInfo>,
90 books: Vec<String>,
91}
92
93impl std::fmt::Debug for Document {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("Document")
96 .field("label", &self.label)
97 .field("sheets", &self.sheets)
98 .field("names", &self.names)
99 .field("tables", &self.tables)
100 .field("books", &self.books)
101 .finish()
102 }
103}
104
105struct Structure {
107 sheets: Vec<(String, Option<String>, usize, usize)>,
109 hidden_styles: HashSet<String>,
110 names: Vec<NameInfo>,
111 ranges: Vec<(String, String)>,
113 books: Vec<String>,
114}
115
116impl Document {
117 pub fn open(bytes: &[u8], label: &str) -> Result<Document, Refusal> {
120 let entries = zip::entries(bytes).ok_or_else(|| {
121 not_a_workbook(
122 label,
123 zip::why_not_an_archive(bytes)
124 .unwrap_or("it is not a zip archive this reader knows"),
125 )
126 })?;
127 let text_of = |name: &str| -> Result<Option<String>, Refusal> {
128 part_text_raw(bytes, &entries, name).map_err(|why| not_a_workbook(label, &why))
129 };
130 if let Some(mime) = text_of("mimetype")? {
131 let mime = mime.trim();
132 if mime != MIMETYPE {
133 let kind = mime.rsplit('.').next().unwrap_or("file");
134 return Err(not_a_workbook(
135 label,
136 &format!("it is an OpenDocument {kind} file, not a spreadsheet"),
137 ));
138 }
139 }
140 let text = text_of(CONTENT)?.ok_or_else(|| {
141 not_a_workbook(
142 label,
143 "it has no content.xml part, so it is not an OpenDocument spreadsheet",
144 )
145 })?;
146 let found = structure(&text, label)?;
147 let sheets: Vec<SheetInfo> = found
148 .sheets
149 .iter()
150 .map(|(name, style, _, _)| SheetInfo {
151 name: name.clone(),
152 visibility: match style {
153 Some(s) if found.hidden_styles.contains(s) => Visibility::Hidden,
154 _ => Visibility::Visible,
155 },
156 part: CONTENT.to_string(),
157 })
158 .collect();
159 let spans: Vec<(usize, usize)> = found.sheets.iter().map(|s| (s.2, s.3)).collect();
160 let mut tables: Vec<TableInfo> = Vec::new();
161 for (name, target) in &found.ranges {
162 let Some((first, second)) = split_range(target) else {
163 continue;
164 };
165 let (sheet_name, from) = parse_part(first);
166 let (_, to) = parse_part(second);
167 let wanted = fold(&sheet_name);
168 let Some(sheet) = sheets.iter().position(|s| fold(&s.name) == wanted) else {
169 continue;
170 };
171 let range = if to.is_empty() || to == from {
172 from.to_string()
173 } else {
174 format!("{from}:{to}")
175 };
176 tables.push(TableInfo {
177 name: name.clone(),
178 sheet,
179 range,
180 columns: Vec::new(),
181 });
182 }
183 Ok(Document {
184 label: label.to_string(),
185 text,
186 sheets,
187 spans,
188 names: found.names,
189 tables,
190 books: found.books,
191 })
192 }
193}
194
195fn structure(text: &str, label: &str) -> Result<Structure, Refusal> {
200 let part = CONTENT;
201 let mut found = Structure {
202 sheets: Vec::new(),
203 hidden_styles: HashSet::new(),
204 names: Vec::new(),
205 ranges: Vec::new(),
206 books: Vec::new(),
207 };
208 let mut cursor = Cursor::new(text);
209 let mut in_styles = false;
210 let mut current_style: Option<String> = None;
211 let mut table_depth = 0usize;
212 let mut current_sheet: Option<usize> = None;
213 loop {
214 let before = cursor.position();
215 let Some(event) = step(&mut cursor, label, part)? else {
216 break;
217 };
218 match event {
219 Event::Start {
220 name: "automatic-styles",
221 empty: false,
222 ..
223 } => in_styles = true,
224 Event::End {
225 name: "automatic-styles",
226 } => in_styles = false,
227 Event::Start {
228 name: "style",
229 attrs,
230 empty,
231 } if in_styles => {
232 if !empty && attr(attrs, "family").as_deref() == Some("table") {
233 current_style = attr(attrs, "name").map(|c| c.into_owned());
234 }
235 }
236 Event::End { name: "style" } => current_style = None,
237 Event::Start {
238 name: "table-properties",
239 attrs,
240 ..
241 } => {
242 if let Some(style) = ¤t_style {
243 if attr(attrs, "display").as_deref() == Some("false") {
244 found.hidden_styles.insert(style.clone());
245 }
246 }
247 }
248 Event::Start {
249 name: "table",
250 attrs,
251 empty,
252 } => {
253 if table_depth == 0 {
254 let name = attr(attrs, "name")
255 .map(|c| c.into_owned())
256 .unwrap_or_else(|| format!("Sheet{}", found.sheets.len() + 1));
257 let style = attr(attrs, "style-name").map(|c| c.into_owned());
258 found.sheets.push((name, style, before, cursor.position()));
259 current_sheet = Some(found.sheets.len() - 1);
260 if empty {
261 current_sheet = None;
262 continue;
263 }
264 }
265 if !empty {
266 table_depth += 1;
267 }
268 }
269 Event::End { name: "table" } => {
270 table_depth = table_depth.saturating_sub(1);
271 if table_depth == 0 {
272 if let Some(i) = current_sheet {
273 found.sheets[i].3 = cursor.position();
274 }
275 current_sheet = None;
276 }
277 }
278 Event::Start {
279 name: "named-range",
280 attrs,
281 ..
282 } => {
283 if let Some(name) = attr(attrs, "name") {
284 let target = attr(attrs, "cell-range-address").unwrap_or_default();
285 let refers_to = convert_reference(&target, &mut found.books);
286 found.names.push(NameInfo {
287 name: name.into_owned(),
288 refers_to,
289 sheet: if table_depth > 0 { current_sheet } else { None },
290 hidden: false,
291 placeholder: false,
292 });
293 }
294 }
295 Event::Start {
296 name: "named-expression",
297 attrs,
298 ..
299 } => {
300 if let Some(name) = attr(attrs, "name") {
301 let expression = attr(attrs, "expression").unwrap_or_default();
302 let refers_to = formula_to_a1(&expression, &mut found.books);
303 found.names.push(NameInfo {
304 name: name.into_owned(),
305 refers_to: refers_to.trim_start_matches('=').to_string(),
306 sheet: if table_depth > 0 { current_sheet } else { None },
307 hidden: false,
308 placeholder: false,
309 });
310 }
311 }
312 Event::Start {
313 name: "database-range",
314 attrs,
315 ..
316 } => {
317 if let (Some(name), Some(target)) =
318 (attr(attrs, "name"), attr(attrs, "target-range-address"))
319 {
320 if !name.starts_with("__Anonymous") {
321 found.ranges.push((name.into_owned(), target.into_owned()));
322 }
323 }
324 }
325 Event::Start {
326 name: "table-cell" | "covered-table-cell",
327 attrs,
328 ..
329 } => {
330 if let Some(formula) = attr(attrs, "formula") {
331 let _ = formula_to_a1(&formula, &mut found.books);
332 }
333 }
334 _ => {}
335 }
336 }
337 Ok(found)
338}
339
340fn is_error_literal(text: &str) -> bool {
343 matches!(
344 text,
345 "#NULL!" | "#DIV/0!" | "#VALUE!" | "#REF!" | "#NAME?" | "#NUM!" | "#N/A"
346 )
347}
348
349struct CellData {
351 repeat: u32,
352 value: Option<Value>,
353 formula: Option<String>,
354 matrix: bool,
355}
356
357fn paragraph_text(cursor: &mut Cursor, label: &str) -> Result<String, Refusal> {
361 let part = CONTENT;
362 let mut out = String::new();
363 let mut depth = 0u32;
364 loop {
365 match step(cursor, label, part)? {
366 None => return Err(xml_refused(label, part, "a paragraph never closes")),
367 Some(Event::Start { name, attrs, empty }) => match name {
368 "s" => {
369 let n = attr(attrs, "c")
370 .and_then(|v| v.parse::<usize>().ok())
371 .unwrap_or(1)
372 .min(1 << 16);
373 for _ in 0..n {
374 out.push(' ');
375 }
376 if !empty {
377 depth += 1;
378 }
379 }
380 "tab" => {
381 out.push('\t');
382 if !empty {
383 depth += 1;
384 }
385 }
386 "line-break" => {
387 out.push('\n');
388 if !empty {
389 depth += 1;
390 }
391 }
392 "span" | "a" | "ruby" | "ruby-base" | "meta" | "meta-field" => {
393 if !empty {
394 depth += 1;
395 }
396 }
397 _ => {
398 if !empty {
399 skip_element(cursor).map_err(|why| xml_refused(label, part, why))?;
400 }
401 }
402 },
403 Some(Event::End { .. }) => {
404 if depth == 0 {
405 return Ok(out);
406 }
407 depth -= 1;
408 }
409 Some(Event::Text { text, raw }) => {
410 if raw {
411 out.push_str(text);
412 } else {
413 out.push_str(&unescape_entities(text));
414 }
415 }
416 }
417 }
418}
419
420fn cell_text(cursor: &mut Cursor, label: &str) -> Result<String, Refusal> {
422 let part = CONTENT;
423 let mut paragraphs: Vec<String> = Vec::new();
424 loop {
425 match step(cursor, label, part)? {
426 None => return Err(xml_refused(label, part, "a cell never closes")),
427 Some(Event::Start {
428 name: "p", empty, ..
429 }) => {
430 if empty {
431 paragraphs.push(String::new());
432 } else {
433 paragraphs.push(paragraph_text(cursor, label)?);
434 }
435 }
436 Some(Event::Start { empty: false, .. }) => {
437 skip_element(cursor).map_err(|why| xml_refused(label, part, why))?;
438 }
439 Some(Event::End { .. }) => return Ok(paragraphs.join("\n")),
440 _ => {}
441 }
442 }
443}
444
445fn read_cell(
447 cursor: &mut Cursor,
448 attrs: &str,
449 empty: bool,
450 label: &str,
451) -> Result<CellData, Refusal> {
452 let repeat = attr(attrs, "number-columns-repeated")
453 .and_then(|v| v.parse::<u32>().ok())
454 .unwrap_or(1)
455 .max(1);
456 let matrix = attr(attrs, "number-matrix-rows-spanned").is_some()
457 || attr(attrs, "number-matrix-columns-spanned").is_some();
458 let formula = attr(attrs, "formula").map(|f| formula_to_a1(&f, &mut Vec::new()));
459 let mut value_type: Option<String> = None;
462 let mut extension_type: Option<String> = None;
463 for (name, value) in attributes_full(attrs) {
464 let local = name.rsplit(':').next().unwrap_or(name);
465 if local == "value-type" {
466 if name.starts_with("calcext:") {
467 extension_type = Some(unescape_entities(value).into_owned());
468 } else {
469 value_type = Some(unescape_entities(value).into_owned());
470 }
471 }
472 }
473 let text = if empty {
474 String::new()
475 } else {
476 cell_text(cursor, label)?
477 };
478 let value = match value_type.as_deref() {
479 Some("float") | Some("percentage") | Some("currency") => {
480 Some(Value::Number(match attr(attrs, "value") {
481 Some(v) => v.trim().to_string(),
482 None => text.trim().to_string(),
483 }))
484 }
485 Some("boolean") => attr(attrs, "boolean-value")
486 .map(|v| Value::Bool(v.trim().eq_ignore_ascii_case("true")))
487 .or_else(|| Some(Value::Bool(text.trim().eq_ignore_ascii_case("true")))),
488 Some("date") => attr(attrs, "date-value").map(|v| Value::Date(v.trim().to_string())),
489 Some("time") => attr(attrs, "time-value").map(|v| Value::Date(v.trim().to_string())),
490 Some("string") => {
491 let text = attr(attrs, "string-value")
492 .map(|v| v.into_owned())
493 .unwrap_or(text);
494 if extension_type.as_deref() == Some("error")
495 || (formula.is_some() && is_error_literal(text.trim()))
496 {
497 Some(Value::Error(text.trim().to_string()))
498 } else {
499 Some(Value::Text(text))
500 }
501 }
502 Some("error") => {
505 let text = attr(attrs, "string-value")
506 .map(|v| v.into_owned())
507 .unwrap_or(text);
508 Some(Value::Error(text.trim().to_string()))
509 }
510 Some("void") => None,
511 _ => {
512 if formula.is_none() && !text.is_empty() {
513 Some(Value::Text(text))
514 } else {
515 None
516 }
517 }
518 };
519 let _ = attr_full(attrs, "office:value-type");
520 Ok(CellData {
521 repeat,
522 value,
523 formula,
524 matrix,
525 })
526}
527
528fn read_row(cursor: &mut Cursor, label: &str) -> Result<Vec<CellData>, Refusal> {
530 let part = CONTENT;
531 let mut cells: Vec<CellData> = Vec::new();
532 loop {
533 match step(cursor, label, part)? {
534 None => return Err(xml_refused(label, part, "a row never closes")),
535 Some(Event::Start {
536 name: "table-cell" | "covered-table-cell",
537 attrs,
538 empty,
539 }) => cells.push(read_cell(cursor, attrs, empty, label)?),
540 Some(Event::Start { empty: false, .. }) => {
541 skip_element(cursor).map_err(|why| xml_refused(label, part, why))?;
542 }
543 Some(Event::End { name: "table-row" }) => return Ok(cells),
544 _ => {}
545 }
546 }
547}
548
549impl Source for Document {
550 fn label(&self) -> &str {
551 &self.label
552 }
553
554 fn sheets(&self) -> &[SheetInfo] {
555 &self.sheets
556 }
557
558 fn names(&self) -> &[NameInfo] {
559 &self.names
560 }
561
562 fn tables(&self) -> &[TableInfo] {
563 &self.tables
564 }
565
566 fn external_books(&self) -> &[String] {
567 &self.books
568 }
569
570 fn summary(&self) -> Summary {
571 Summary {
572 names: self.names.len(),
573 placeholders: 0,
574 tables: self.tables.len(),
575 external_books: self.books.len(),
576 strings: 0,
577 string_bytes: 0,
578 }
579 }
580
581 fn walk_sheet_with(
582 &self,
583 index: usize,
584 sink: &mut dyn Sink,
585 book_distinct: &mut HashSet<String>,
586 ) -> Result<SheetStats, Refusal> {
587 let part = CONTENT;
588 let label = self.label.as_str();
589 let info = &self.sheets[index];
590 let (start, end) = self.spans[index];
591 let slice = &self.text[start..end];
592 let mut stats = SheetStats {
593 part_bytes: slice.len(),
594 ..SheetStats::default()
595 };
596 let mut sheet_distinct: HashSet<String> = HashSet::new();
597 let mut cursor = Cursor::new(slice);
598 let mut opened = false;
599 let mut row_no: u32 = 0;
600 while let Some(event) = step(&mut cursor, label, part)? {
601 match event {
602 Event::Start {
603 name: "table",
604 empty,
605 ..
606 } if !opened => {
607 if empty {
608 break;
609 }
610 opened = true;
611 }
612 Event::End { name: "table" } => break,
613 Event::Start {
614 name: "table-row",
615 attrs,
616 empty,
617 } => {
618 let repeat = attr(attrs, "number-rows-repeated")
619 .and_then(|v| v.parse::<u32>().ok())
620 .unwrap_or(1)
621 .max(1);
622 if empty {
623 row_no = row_no.saturating_add(repeat);
624 continue;
625 }
626 let cells = read_row(&mut cursor, label)?;
627 if cells
632 .iter()
633 .all(|c| c.value.is_none() && c.formula.is_none())
634 {
635 row_no = row_no.saturating_add(repeat);
636 continue;
637 }
638 for r in 0..repeat {
639 let row = row_no.saturating_add(1).saturating_add(r);
640 if row > MAX_ROW {
641 break;
642 }
643 let mut col: u32 = 0;
644 for cell in &cells {
645 if cell.value.is_none() && cell.formula.is_none() {
646 col = col.saturating_add(cell.repeat);
647 continue;
648 }
649 for c in 0..cell.repeat {
650 let at = col.saturating_add(1).saturating_add(c);
651 if at > MAX_COLUMN {
652 break;
653 }
654 let addr = cell_ref(row, at);
655 stats.cells += 1;
656 stats.rows = stats.rows.max(row);
657 stats.columns = stats.columns.max(at);
658 if let Some(value) = &cell.value {
659 sink.row(&Row::Cell {
660 sheet: &info.name,
661 addr: &addr,
662 value,
663 });
664 }
665 if let Some(text) = &cell.formula {
666 if cell.matrix {
667 stats.array_anchors += 1;
668 }
669 emit_formula(
670 sink,
671 &info.name,
672 &addr,
673 text,
674 row,
675 at,
676 &mut stats,
677 &mut sheet_distinct,
678 book_distinct,
679 );
680 }
681 }
682 col = col.saturating_add(cell.repeat);
683 }
684 }
685 row_no = row_no.saturating_add(repeat);
686 }
687 Event::Start {
688 name: "table-header-rows" | "table-rows" | "table-row-group",
689 ..
690 } => {}
691 Event::Start { empty: false, .. } => {
692 skip_element(&mut cursor).map_err(|why| xml_refused(label, part, why))?;
693 }
694 _ => {}
695 }
696 }
697 stats.distinct_r1c1 = sheet_distinct.len() as u64;
698 Ok(stats)
699 }
700}
701
702pub fn formula_to_a1(text: &str, books: &mut Vec<String>) -> String {
707 let body = match text.find(":=") {
709 Some(i)
710 if text[..i]
711 .chars()
712 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
713 && !text[..i].is_empty() =>
714 {
715 &text[i + 1..]
716 }
717 _ => text,
718 };
719 let chars: Vec<char> = body.chars().collect();
720 let mut out = String::with_capacity(body.len() + 1);
721 if chars.first() != Some(&'=') {
722 out.push('=');
723 }
724 out.push_str(&convert_body(&chars, books));
725 out
726}
727
728fn matching_paren(chars: &[char], open: usize) -> usize {
731 let mut depth = 0usize;
732 let mut i = open;
733 while i < chars.len() {
734 match chars[i] {
735 '"' => {
736 i += 1;
737 while i < chars.len() {
738 if chars[i] == '"' {
739 if chars.get(i + 1) == Some(&'"') {
740 i += 2;
741 continue;
742 }
743 break;
744 }
745 i += 1;
746 }
747 }
748 '[' => {
749 let mut quoted = false;
750 i += 1;
751 while i < chars.len() {
752 if chars[i] == '\'' {
753 quoted = !quoted;
754 } else if chars[i] == ']' && !quoted {
755 break;
756 }
757 i += 1;
758 }
759 }
760 '(' => depth += 1,
761 ')' => {
762 depth -= 1;
763 if depth == 0 {
764 return i;
765 }
766 }
767 _ => {}
768 }
769 i += 1;
770 }
771 chars.len()
772}
773
774fn ahead_is(chars: &[char], i: usize, word: &str) -> bool {
776 let at_boundary = i == 0 || !(chars[i - 1].is_ascii_alphanumeric() || chars[i - 1] == '.');
777 at_boundary
778 && chars.len() >= i + word.chars().count()
779 && chars[i..i + word.chars().count()]
780 .iter()
781 .copied()
782 .eq(word.chars())
783}
784
785fn convert_body(chars: &[char], books: &mut Vec<String>) -> String {
788 const SINGLE: &str = "COM.MICROSOFT.SINGLE(";
789 const MICROSOFT: &str = "COM.MICROSOFT.";
790 let mut out = String::with_capacity(chars.len());
791 let mut i = 0;
792 while i < chars.len() {
793 let c = chars[i];
794 match c {
795 '"' => {
796 out.push('"');
798 i += 1;
799 while i < chars.len() {
800 out.push(chars[i]);
801 if chars[i] == '"' {
802 if chars.get(i + 1) == Some(&'"') {
803 out.push('"');
804 i += 2;
805 continue;
806 }
807 i += 1;
808 break;
809 }
810 i += 1;
811 }
812 }
813 '[' => {
814 let mut j = i + 1;
815 let mut quoted = false;
816 while j < chars.len() {
817 if chars[j] == '\'' {
818 quoted = !quoted;
819 } else if chars[j] == ']' && !quoted {
820 break;
821 }
822 j += 1;
823 }
824 let inner: String = chars[i + 1..j.min(chars.len())].iter().collect();
825 out.push_str(&convert_reference(&inner, books));
826 i = j + 1;
827 }
828 '#' => {
829 let mut j = i + 1;
831 while j < chars.len()
832 && (chars[j].is_ascii_alphanumeric() || matches!(chars[j], '/' | '?' | '!'))
833 {
834 j += 1;
835 }
836 out.extend(&chars[i..j]);
837 i = j;
838 }
839 ';' | '~' => {
840 out.push(',');
841 i += 1;
842 }
843 '!' => {
844 out.push(' ');
845 i += 1;
846 }
847 '$' if chars.get(i + 1) == Some(&'$') => {
849 i += 2;
850 }
851 _ => {
852 if ahead_is(chars, i, SINGLE) {
857 let open = i + SINGLE.chars().count() - 1;
858 let close = matching_paren(chars, open);
859 out.push('@');
860 out.push_str(&convert_body(
861 &chars[open + 1..close.min(chars.len())],
862 books,
863 ));
864 i = close + 1;
865 continue;
866 }
867 if ahead_is(chars, i, MICROSOFT) {
868 i += MICROSOFT.chars().count();
869 continue;
870 }
871 out.push(c);
872 i += 1;
873 }
874 }
875 }
876 out
877}
878
879fn split_range(inner: &str) -> Option<(&str, &str)> {
882 let mut quoted = false;
883 for (i, c) in inner.char_indices() {
884 match c {
885 '\'' => quoted = !quoted,
886 ':' if !quoted => return Some((&inner[..i], &inner[i + 1..])),
887 _ => {}
888 }
889 }
890 Some((inner, ""))
891}
892
893fn parse_part(part: &str) -> (String, &str) {
897 let part = part.strip_prefix('$').unwrap_or(part);
898 if let Some(rest) = part.strip_prefix('\'') {
899 let chars: Vec<(usize, char)> = rest.char_indices().collect();
901 let mut name = String::new();
902 let mut k = 0;
903 while k < chars.len() {
904 let (at, c) = chars[k];
905 if c == '\'' {
906 if chars.get(k + 1).map(|&(_, n)| n) == Some('\'') {
907 name.push('\'');
908 k += 2;
909 continue;
910 }
911 let after = &rest[at + 1..];
912 return (name, after.strip_prefix('.').unwrap_or(after));
913 }
914 name.push(c);
915 k += 1;
916 }
917 return (name, "");
918 }
919 match part.find('.') {
920 Some(i) => (part[..i].to_string(), &part[i + 1..]),
921 None => (String::new(), part),
922 }
923}
924
925fn file_name_of(iri: &str) -> String {
928 let tail = iri.rsplit(['/', '\\']).next().unwrap_or(iri);
929 let bytes = tail.as_bytes();
930 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
931 let mut i = 0;
932 while i < bytes.len() {
933 if bytes[i] == b'%' {
934 if let Some(v) = tail
935 .get(i + 1..i + 3)
936 .and_then(|hex| u8::from_str_radix(hex, 16).ok())
937 {
938 out.push(v);
939 i += 3;
940 continue;
941 }
942 }
943 out.push(bytes[i]);
944 i += 1;
945 }
946 String::from_utf8(out).unwrap_or_else(|_| tail.to_string())
947}
948
949fn convert_reference(inner: &str, books: &mut Vec<String>) -> String {
953 let mut rest = inner.trim();
954 let mut book: Option<String> = None;
956 if let Some(after_quote) = rest.strip_prefix('\'') {
957 let mut end: Option<usize> = None;
958 let chars: Vec<(usize, char)> = after_quote.char_indices().collect();
959 let mut k = 0;
960 while k < chars.len() {
961 if chars[k].1 == '\'' {
962 if chars.get(k + 1).map(|&(_, c)| c) == Some('\'') {
963 k += 2;
964 continue;
965 }
966 end = Some(chars[k].0);
967 break;
968 }
969 k += 1;
970 }
971 if let Some(e) = end {
972 if after_quote[e + 1..].starts_with('#') {
973 let iri = after_quote[..e].replace("''", "'");
974 let name = file_name_of(&iri);
975 if !books.iter().any(|b| fold(b) == fold(&name)) {
976 books.push(name.clone());
977 }
978 book = Some(name);
979 rest = &after_quote[e + 2..];
980 }
981 }
982 }
983 let Some((first, second)) = split_range(rest) else {
984 return rest.to_string();
985 };
986 let (sheet1, addr1) = parse_part(first);
987 let (sheet2, addr2) = if second.is_empty() {
988 (String::new(), "")
989 } else {
990 parse_part(second)
991 };
992 let addr = if addr2.is_empty()
993 || (addr2 == addr1 && !sheet2.is_empty() && fold(&sheet2) != fold(&sheet1))
994 {
995 addr1.to_string()
996 } else {
997 format!("{addr1}:{addr2}")
998 };
999 let prefix = if sheet1.is_empty() && sheet2.is_empty() {
1000 String::new()
1001 } else if sheet2.is_empty() || fold(&sheet2) == fold(&sheet1) {
1002 let name = if sheet1.is_empty() { &sheet2 } else { &sheet1 };
1003 match &book {
1004 Some(b) => quote_book_sheet(b, name),
1005 None => format!("{}!", refers::quote_sheet(name)),
1006 }
1007 } else {
1008 let span = format!("{sheet1}:{sheet2}");
1010 let needs =
1011 refers::quote_sheet(&sheet1) != sheet1 || refers::quote_sheet(&sheet2) != sheet2;
1012 let spelled = if needs {
1013 format!("'{}'", span.replace('\'', "''"))
1014 } else {
1015 span
1016 };
1017 match &book {
1018 Some(b) => format!("[{b}]{spelled}!"),
1019 None => format!("{spelled}!"),
1020 }
1021 };
1022 format!("{prefix}{addr}")
1023}
1024
1025fn quote_book_sheet(book: &str, sheet: &str) -> String {
1027 if refers::quote_sheet(sheet) == sheet {
1028 format!("[{book}]{sheet}!")
1029 } else {
1030 format!("'[{book}]{}'!", sheet.replace('\'', "''"))
1031 }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037 use crate::reflect::{open, reflect_text};
1038
1039 const OFFICE: &str = "xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:style=\"urn:oasis:names:tc:opendocument:xmlns:style:1.0\" xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" xmlns:of=\"urn:oasis:names:tc:opendocument:xmlns:of:1.2\" xmlns:calcext=\"urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0\" office:version=\"1.2\"";
1040
1041 fn ods(content: &str, mimetype: &str) -> Vec<u8> {
1042 zip::write_stored(&[
1043 ("mimetype".to_string(), mimetype.as_bytes().to_vec()),
1044 (CONTENT.to_string(), content.as_bytes().to_vec()),
1045 ])
1046 .unwrap()
1047 }
1048
1049 #[test]
1050 fn an_openformula_text_reads_as_the_formula_bar_spells_it() {
1051 let mut books = Vec::new();
1052 let cases = [
1053 ("of:=SUM([.B1:.B2])", "=SUM(B1:B2)"),
1054 ("of:=[.B1]*2", "=B1*2"),
1055 ("of:=SUM([Data.B2:Data.B4])", "=SUM(Data!B2:B4)"),
1056 ("of:=SUM([Data.B2:.B4])", "=SUM(Data!B2:B4)"),
1057 (
1058 "of:=['Q1 Data'.B2]+['It''s'.A1]",
1059 "='Q1 Data'!B2+'It''s'!A1",
1060 ),
1061 ("of:=SUM([Model.B1:Scratch.B1])", "=SUM(Model:Scratch!B1)"),
1062 (
1063 "of:=SUM(['Q1 Data'.A1:'Q4 Data'.A1])",
1064 "=SUM('Q1 Data:Q4 Data'!A1)",
1065 ),
1066 ("of:=SUM([Jan.A1:Mar.B2])", "=SUM(Jan:Mar!A1:B2)"),
1067 ("of:=['Rates.xlsx'#$Sheet1.A1]", "=[Rates.xlsx]Sheet1!A1"),
1068 (
1069 "of:=['file:///C:/Models/Rates%20Q1.xlsx'#$'Sheet 1'.A1:.B2]",
1070 "='[Rates Q1.xlsx]Sheet 1'!A1:B2",
1071 ),
1072 ("of:=SUM([.B:.B])", "=SUM(B:B)"),
1073 ("of:=SUM([.1:.3])", "=SUM(1:3)"),
1074 ("of:=INDIRECT(\"B1\")", "=INDIRECT(\"B1\")"),
1075 ("of:=OFFSET([.B1];1;0)", "=OFFSET(B1,1,0)"),
1076 ("of:=COM.MICROSOFT.SEQUENCE(2)", "=SEQUENCE(2)"),
1077 ("of:=[.A1]&\"a;b~c!\"", "=A1&\"a;b~c!\""),
1078 ("of:=\"x\"\"y\"", "=\"x\"\"y\""),
1079 ("of:=[.#REF!]", "=#REF!"),
1080 ("of:=#REF!+#N/A", "=#REF!+#N/A"),
1081 ("of:=SUM([.A1:.A3]~[.C1])", "=SUM(A1:A3,C1)"),
1082 ("of:=SUM([.A1:.B2]![.B1:.C3])", "=SUM(A1:B2 B1:C3)"),
1083 ("oooc:=[.A1]", "=A1"),
1084 ("=A1", "=A1"),
1085 ("A1+1", "=A1+1"),
1086 ("of:=[$Model.$B$2]", "=Model!$B$2"),
1087 ("of:=[Model.B1]+[.B2]*Rate", "=Model!B1+B2*Rate"),
1088 (
1089 "of:=ORG.OPENOFFICE.STYLE(\"Default\")",
1090 "=ORG.OPENOFFICE.STYLE(\"Default\")",
1091 ),
1092 ("of:=COM.MICROSOFT.SINGLE($$HiddenName)", "=@HiddenName"),
1095 ("of:=[.B3]*COM.MICROSOFT.SINGLE($$Rate)", "=B3*@Rate"),
1096 (
1097 "of:=COM.MICROSOFT.SINGLE(INDIRECT(\"B1\"))",
1098 "=@INDIRECT(\"B1\")",
1099 ),
1100 (
1101 "of:=COM.MICROSOFT.SINGLE([.A1:.A3])+COM.MICROSOFT.SINGLE($$Deep)",
1102 "=@A1:A3+@Deep",
1103 ),
1104 ("of:=COM.MICROSOFT.SINGLE($$Rate", "=@Rate"),
1105 ];
1106 for (given, want) in cases {
1107 assert_eq!(formula_to_a1(given, &mut books), want, "{given}");
1108 }
1109 assert_eq!(
1110 books,
1111 vec!["Rates.xlsx".to_string(), "Rates Q1.xlsx".to_string()]
1112 );
1113 assert_eq!(
1115 convert_reference("$Data.$A$2:.$B$4", &mut books),
1116 "Data!$A$2:$B$4"
1117 );
1118 assert_eq!(convert_reference("$Model.$B$2", &mut books), "Model!$B$2");
1119 assert_eq!(file_name_of("file:///C:/x/y/Rates.xlsx"), "Rates.xlsx");
1120 assert_eq!(file_name_of("Rates.xlsx"), "Rates.xlsx");
1121 }
1122
1123 #[test]
1124 fn a_document_reads_as_the_relations() {
1125 let content = format!(concat!(
1126 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
1127 "<office:document-content {OFFICE}>",
1128 "<office:automatic-styles>",
1129 "<style:style style:name=\"ta1\" style:family=\"table\"><style:table-properties table:display=\"true\"/></style:style>",
1130 "<style:style style:name=\"ta2\" style:family=\"table\"><style:table-properties table:display=\"false\"/></style:style>",
1131 "<style:style style:name=\"ce1\" style:family=\"table-cell\"/>",
1132 "</office:automatic-styles>",
1133 "<office:body><office:spreadsheet>",
1134 "<table:table table:name=\"Model\" table:style-name=\"ta1\">",
1135 "<table:table-column table:number-columns-repeated=\"4\"/>",
1136 "<table:table-row>",
1137 "<table:table-cell office:value-type=\"string\"><text:p>Rate <text:span text:style-name=\"T1\">applies</text:span></text:p></table:table-cell>",
1138 "<table:table-cell office:value-type=\"float\" office:value=\"1200\"><text:p>1200</text:p></table:table-cell>",
1139 "<table:table-cell table:formula=\"of:=[.B1]*2\" office:value-type=\"float\" office:value=\"2400\"><text:p>2400</text:p></table:table-cell>",
1140 "<table:table-cell table:formula=\"of:=1/0\" office:value-type=\"string\" calcext:value-type=\"error\"><text:p>#DIV/0!</text:p></table:table-cell>",
1141 "<table:table-cell table:formula=\"of:=NA()\" office:value-type=\"error\" office:string-value=\"#N/A\"><text:p>#N/A</text:p></table:table-cell>",
1142 "</table:table-row>",
1143 "<table:table-row table:number-rows-repeated=\"2\">",
1144 "<table:table-cell office:value-type=\"boolean\" office:boolean-value=\"true\"><text:p>TRUE</text:p></table:table-cell>",
1145 "<table:table-cell table:style-name=\"ce1\"/>",
1146 "<table:table-cell office:value-type=\"date\" office:date-value=\"2025-09-30\"><text:p>30/09/25</text:p></table:table-cell>",
1147 "<table:table-cell office:value-type=\"time\" office:time-value=\"PT12H30M00S\"><text:p>12:30</text:p></table:table-cell>",
1148 "</table:table-row>",
1149 "<table:table-row>",
1150 "<table:table-cell table:formula=\"of:=[.B1:.B2]*2\" table:number-matrix-columns-spanned=\"1\" table:number-matrix-rows-spanned=\"2\" office:value-type=\"float\" office:value=\"2400\"><text:p>2400</text:p></table:table-cell>",
1151 "<table:table-cell office:value-type=\"string\" table:number-columns-repeated=\"2\"><text:p>a<text:s text:c=\"2\"/>b</text:p><text:p>c</text:p></table:table-cell>",
1152 "<table:table-cell table:formula=\"of:=['Rates.xlsx'#$Sheet1.A1]\" office:value-type=\"string\"><text:p>#REF!</text:p><office:annotation><text:p>a note</text:p></office:annotation></table:table-cell>",
1153 "</table:table-row>",
1154 "<table:named-expressions><table:named-range table:name=\"Local\" table:base-cell-address=\"$Model.$A$1\" table:cell-range-address=\"$Model.$B$1\"/></table:named-expressions>",
1155 "</table:table>",
1156 "<table:table table:name=\"Q1 Data\" table:style-name=\"ta2\">",
1157 "<table:table-row><table:table-cell table:number-columns-repeated=\"2\"/></table:table-row>",
1158 "<table:table-row><table:table-cell/><table:table-cell office:value-type=\"percentage\" office:value=\"0.2\"><text:p>20%</text:p></table:table-cell></table:table-row>",
1159 "</table:table>",
1160 "<table:named-expressions>",
1161 "<table:named-expression table:name=\"Rate\" table:base-cell-address=\"$Model.$A$1\" table:expression=\"of:=0.2\"/>",
1162 "<table:named-range table:name=\"Range1\" table:base-cell-address=\"$Model.$A$1\" table:cell-range-address=\"$'Q1 Data'.$A$2:.$B$2\"/>",
1163 "</table:named-expressions>",
1164 "<table:database-ranges>",
1165 "<table:database-range table:name=\"__Anonymous_Sheet_DB__0\" table:target-range-address=\"Model.A1:Model.D1\"/>",
1166 "<table:database-range table:name=\"Sales\" table:target-range-address=\"'Q1 Data'.A1:'Q1 Data'.B2\"/>",
1167 "</table:database-ranges>",
1168 "</office:spreadsheet></office:body></office:document-content>"), OFFICE = OFFICE);
1169 let bytes = ods(&content, MIMETYPE);
1170 assert!(is_opendocument(&bytes));
1171 let got = reflect_text(&bytes, "t.ods").unwrap();
1172 let want = concat!(
1173 "(sheet \"Model\" visible)\n",
1174 "(sheet \"Q1 Data\" hidden)\n",
1175 "(name \"Model!Local\" \"Model!$B$1\")\n",
1176 "(name \"Range1\" \"'Q1 Data'!$A$2:$B$2\")\n",
1177 "(name \"Rate\" \"0.2\")\n",
1178 "(table \"Sales\" \"Q1 Data\" \"A1:B2\")\n",
1179 "(cell \"Model\" \"A1\" \"Rate applies\")\n",
1180 "(cell \"Model\" \"B1\" 1200)\n",
1181 "(cell \"Model\" \"C1\" 2400)\n",
1182 "(formula \"Model\" \"C1\" \"=B1*2\")\n",
1183 "(refers \"Model!C1\" \"Model!B1\")\n",
1184 "(cell \"Model\" \"D1\" (error \"#DIV/0!\"))\n",
1185 "(formula \"Model\" \"D1\" \"=1/0\")\n",
1186 "(cell \"Model\" \"E1\" (error \"#N/A\"))\n",
1187 "(formula \"Model\" \"E1\" \"=NA()\")\n",
1188 "(cell \"Model\" \"A2\" true)\n",
1189 "(cell \"Model\" \"C2\" (date \"2025-09-30\"))\n",
1190 "(cell \"Model\" \"D2\" (date \"PT12H30M00S\"))\n",
1191 "(cell \"Model\" \"A3\" true)\n",
1192 "(cell \"Model\" \"C3\" (date \"2025-09-30\"))\n",
1193 "(cell \"Model\" \"D3\" (date \"PT12H30M00S\"))\n",
1194 "(cell \"Model\" \"A4\" 2400)\n",
1195 "(formula \"Model\" \"A4\" \"=B1:B2*2\")\n",
1196 "(refers \"Model!A4\" \"Model!B1:B2\")\n",
1197 "(cell \"Model\" \"B4\" \"a b\nc\")\n",
1200 "(cell \"Model\" \"C4\" \"a b\nc\")\n",
1201 "(cell \"Model\" \"D4\" (error \"#REF!\"))\n",
1202 "(formula \"Model\" \"D4\" \"=[Rates.xlsx]Sheet1!A1\")\n",
1203 "(refers \"Model!D4\" \"[Rates.xlsx]Sheet1!A1\")\n",
1204 "(cell \"Q1 Data\" \"B2\" 0.2)\n",
1205 );
1206 assert_eq!(got, want);
1207 let doc = Document::open(&bytes, "t.ods").unwrap();
1208 let summary = doc.summary();
1209 assert_eq!(
1210 (summary.names, summary.tables, summary.external_books),
1211 (3, 1, 1)
1212 );
1213 let mut distinct = HashSet::new();
1214 let stats = doc
1215 .walk_sheet_with(0, &mut super::super::Discard, &mut distinct)
1216 .unwrap();
1217 assert_eq!(
1218 (
1219 stats.cells,
1220 stats.formulas,
1221 stats.array_anchors,
1222 stats.rows,
1223 stats.columns
1224 ),
1225 (15, 5, 1, 4, 5)
1226 );
1227 assert_eq!((stats.distinct_r1c1, stats.unreadable), (5, 0));
1228 }
1229
1230 #[test]
1231 fn what_is_not_a_spreadsheet_is_refused_by_name() {
1232 let text_doc = ods(
1233 "<office:document-content/>",
1234 "application/vnd.oasis.opendocument.text",
1235 );
1236 assert!(is_opendocument(&text_doc));
1237 let r = open(&text_doc, "letter.odt").unwrap_err();
1238 assert_eq!(r.id, "reflect-not-a-workbook");
1239 assert!(
1240 r.text.contains("OpenDocument text file, not a spreadsheet"),
1241 "{}",
1242 r.text
1243 );
1244 let no_content =
1245 zip::write_stored(&[("mimetype".to_string(), MIMETYPE.as_bytes().to_vec())]).unwrap();
1246 let r = open(&no_content, "x.ods").unwrap_err();
1247 assert!(r.text.contains("no content.xml"), "{}", r.text);
1248 let doctype = ods("<!DOCTYPE x><office:document-content/>", MIMETYPE);
1249 let r = open(&doctype, "d.ods").unwrap_err();
1250 assert_eq!(r.id, "reflect-xml-refused");
1251 let pkg =
1253 zip::write_stored(&[("xl/workbook.xml".to_string(), b"<workbook/>".to_vec())]).unwrap();
1254 assert!(!is_opendocument(&pkg));
1255 assert!(!is_opendocument(b"nope"));
1256 }
1257}