imprimatur 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/Cargo.lock +124 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +123 -23
  6. data/ext/imprimatur_native/cli/Cargo.toml +2 -2
  7. data/ext/imprimatur_native/cli/src/cli/grep.rs +1 -1
  8. data/ext/imprimatur_native/cli/src/cli/markdown.rs +2 -2
  9. data/ext/imprimatur_native/cli/src/cli.rs +5 -5
  10. data/ext/imprimatur_native/cli/src/main.rs +1 -1
  11. data/ext/imprimatur_native/core/Cargo.toml +12 -2
  12. data/ext/imprimatur_native/core/README.md +15 -1
  13. data/ext/imprimatur_native/core/src/archive.rs +279 -0
  14. data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
  15. data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
  16. data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
  17. data/ext/imprimatur_native/core/src/doc.rs +86 -2
  18. data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
  19. data/ext/imprimatur_native/core/src/docx.rs +91 -28
  20. data/ext/imprimatur_native/core/src/error.rs +13 -1
  21. data/ext/imprimatur_native/core/src/font.rs +229 -0
  22. data/ext/imprimatur_native/core/src/lib.rs +5 -0
  23. data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
  24. data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
  25. data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
  26. data/ext/imprimatur_native/core/src/pdf.rs +31 -3
  27. data/ext/imprimatur_native/core/src/read/content.rs +91 -3
  28. data/ext/imprimatur_native/core/src/read/json.rs +96 -7
  29. data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
  30. data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +69 -45
  31. data/ext/imprimatur_native/core/src/read/ods.rs +356 -0
  32. data/ext/imprimatur_native/core/src/read/ole.rs +244 -0
  33. data/ext/imprimatur_native/core/src/read/package.rs +0 -5
  34. data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
  35. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
  36. data/ext/imprimatur_native/core/src/read/xls/strings.rs +156 -0
  37. data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
  38. data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
  39. data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -20
  40. data/ext/imprimatur_native/core/src/read.rs +68 -21
  41. data/ext/imprimatur_native/core/src/script.rs +116 -0
  42. data/ext/imprimatur_native/core/src/style.rs +21 -0
  43. data/ext/imprimatur_native/core/src/test_support.rs +90 -2
  44. data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
  45. data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
  46. data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
  47. data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
  48. data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
  49. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
  50. data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
  51. data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
  52. data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
  53. data/ext/imprimatur_native/ffi/src/archive.rs +119 -0
  54. data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
  55. data/ext/imprimatur_native/ffi/src/document.rs +48 -1
  56. data/ext/imprimatur_native/ffi/src/lib.rs +52 -1
  57. data/ext/imprimatur_native/ffi/src/read.rs +9 -14
  58. data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
  59. data/lib/imprimatur/document.rb +30 -6
  60. data/lib/imprimatur/error.rb +4 -3
  61. data/lib/imprimatur/reader.rb +3 -3
  62. data/lib/imprimatur/spreadsheet.rb +151 -0
  63. data/lib/imprimatur/version.rb +1 -1
  64. data/lib/imprimatur/xlsx.rb +25 -4
  65. data/lib/imprimatur/zip.rb +127 -0
  66. data/lib/imprimatur.rb +18 -5
  67. metadata +23 -4
@@ -9,16 +9,35 @@ mod tests;
9
9
 
10
10
  use serde_json::{Map, Value as Json, json};
11
11
 
12
- use super::{Block, Bounds, Cell, Content, Grid, Image, ImageLocation, Mark, Page, Sheet, Value};
12
+ use super::{
13
+ Block, Bounds, Cell, Columns, Content, Grid, Image, ImageLocation, Look, Mark, Page, Sheet,
14
+ Styled, Validation, Value,
15
+ };
16
+ use crate::xlsx::column_name;
17
+
18
+ /// What [`content_with`] adds to the content.
19
+ #[derive(Debug, Clone, Copy, Default)]
20
+ pub struct Options {
21
+ /// Each formatted cell of a sheet, with its look.
22
+ pub styles: bool,
23
+ }
13
24
 
14
25
  /// The document's content, by format: `pages`, `sheets` or `blocks`.
15
26
  #[must_use]
16
27
  pub fn content(content: &Content) -> Map<String, Json> {
28
+ content_with(content, Options::default())
29
+ }
30
+
31
+ /// The document's content, with what `options` add.
32
+ #[must_use]
33
+ pub fn content_with(content: &Content, options: Options) -> Map<String, Json> {
17
34
  let mut object = Map::new();
18
35
  object.insert("format".into(), content.format().as_str().into());
19
36
  match content {
20
37
  Content::Pdf(pages) => object.insert("pages".into(), pages.iter().map(page).collect()),
21
- Content::Xlsx(sheets) => object.insert("sheets".into(), sheets.iter().map(sheet).collect()),
38
+ Content::Xlsx(sheets) | Content::Ods(sheets) | Content::Xls(sheets) => {
39
+ object.insert("sheets".into(), sheets.iter().map(|it| sheet(it, options)).collect())
40
+ }
22
41
  Content::Docx(blocks) => object.insert("blocks".into(), blocks.iter().map(block).collect()),
23
42
  };
24
43
  object
@@ -114,7 +133,19 @@ fn with_marks(mut object: Json, marks: &[Mark]) -> Json {
114
133
  object
115
134
  }
116
135
 
117
- fn sheet(sheet: &Sheet) -> Json {
136
+ fn sheet(sheet: &Sheet, options: Options) -> Json {
137
+ let mut object = Map::new();
138
+ object.insert("name".into(), sheet.name.clone().into());
139
+ object.insert("hidden".into(), sheet.hidden.into());
140
+ object.insert("rows".into(), rows(&sheet.cells, true));
141
+ object.extend(sheet_details(sheet, options));
142
+ Json::Object(object)
143
+ }
144
+
145
+ /// What a sheet has besides its values: `comments`, `columns`,
146
+ /// `validations` and, with `options.styles`, `styles`.
147
+ #[must_use]
148
+ pub fn sheet_details(sheet: &Sheet, options: Options) -> Map<String, Json> {
118
149
  let comments: Vec<Json> = sheet
119
150
  .comments
120
151
  .iter()
@@ -123,11 +154,69 @@ fn sheet(sheet: &Sheet) -> Json {
123
154
  json!({ "cell": note.reference(), "author": author, "text": note.text })
124
155
  })
125
156
  .collect();
157
+ let mut details = Map::new();
158
+ details.insert("comments".into(), comments.into());
159
+ details.insert("columns".into(), sheet.columns.iter().map(columns).collect());
160
+ details.insert("validations".into(), sheet.validations.iter().map(validation).collect());
161
+ if options.styles {
162
+ details.insert("styles".into(), sheet.styles.iter().map(styled).collect());
163
+ }
164
+ details
165
+ }
166
+
167
+ /// Columns by letter: `A`, `XFD`.
168
+ fn columns(run: &Columns) -> Json {
169
+ let name = |column: u32| column_name(u16::try_from(column).unwrap_or(u16::MAX));
170
+ let (first, last) = (name(run.first), name(run.last));
171
+ json!({ "first": first, "last": last, "width": run.width, "hidden": run.hidden })
172
+ }
173
+
174
+ fn validation(rule: &Validation) -> Json {
175
+ json!({
176
+ "range": rule.range,
177
+ "type": rule.kind,
178
+ "operator": rule.operator,
179
+ "formula1": rule.formula1,
180
+ "formula2": rule.formula2,
181
+ "allow_blank": rule.allow_blank,
182
+ "dropdown": rule.dropdown,
183
+ "alert": rule.alert,
184
+ "error_title": rule.error_title,
185
+ "error": rule.error,
186
+ "prompt_title": rule.prompt_title,
187
+ "prompt": rule.prompt,
188
+ })
189
+ }
190
+
191
+ fn styled(cell: &Styled) -> Json {
192
+ let Look {
193
+ font,
194
+ size,
195
+ bold,
196
+ italic,
197
+ underline,
198
+ color,
199
+ fill,
200
+ align,
201
+ valign,
202
+ wrap,
203
+ border,
204
+ number_format,
205
+ } = cell.look.as_ref();
126
206
  json!({
127
- "name": sheet.name,
128
- "hidden": sheet.hidden,
129
- "rows": rows(&sheet.cells, true),
130
- "comments": comments,
207
+ "cell": cell.reference(),
208
+ "font": font,
209
+ "size": size,
210
+ "bold": bold,
211
+ "italic": italic,
212
+ "underline": underline,
213
+ "color": color,
214
+ "fill": fill,
215
+ "align": align,
216
+ "valign": valign,
217
+ "wrap": wrap,
218
+ "border": border,
219
+ "number_format": number_format,
131
220
  })
132
221
  }
133
222
 
@@ -23,7 +23,7 @@ impl Content {
23
23
  let mut out = String::new();
24
24
  match self {
25
25
  Self::Pdf(pages) => pdf(&mut out, pages),
26
- Self::Xlsx(sheets) => {
26
+ Self::Xlsx(sheets) | Self::Ods(sheets) | Self::Xls(sheets) => {
27
27
  for sheet in sheets {
28
28
  write_sheet(&mut out, sheet);
29
29
  }
@@ -1,13 +1,11 @@
1
- //! How XLSX number formats show numbers: which formats are dates, times
2
- //! or percentages, and Excel's General format.
1
+ //! How spreadsheet number formats show numbers: which formats are dates,
2
+ //! times or percentages, and Excel's General format. XLSX and XLS share
3
+ //! Excel's format codes and built-in formats.
3
4
 
4
5
  use std::fmt::Write as _;
5
6
 
6
- use crate::Result;
7
- use crate::read::Budget;
8
7
  use crate::read::content::Value;
9
8
  use crate::xlsx::DateTime;
10
- use crate::xml::reader::{Event, XmlReader};
11
9
 
12
10
  /// How a number format shows a number.
13
11
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -16,43 +14,24 @@ pub enum Shown {
16
14
  Date,
17
15
  Time,
18
16
  DateTime,
19
- Percent,
20
- }
21
-
22
- /// How each cell format (`cellXfs`, by index) shows numbers.
23
- pub fn styles(text: &str, budget: &Budget<'_>) -> Result<Vec<Shown>> {
24
- let mut xml = XmlReader::new(text, budget.max_depth());
25
- let (mut custom, mut shown, mut in_cell_formats) = (Vec::new(), Vec::new(), false);
26
- while let Some(event) = xml.next()? {
27
- match event {
28
- Event::Start(tag) => match tag.name() {
29
- "numFmt" => {
30
- if let (Some(id), Some(code)) = (tag.attr("numFmtId"), tag.attr("formatCode")) {
31
- custom.push((id.parse::<u32>().unwrap_or(u32::MAX), classify(&code)));
32
- }
33
- }
34
- "cellXfs" => in_cell_formats = true,
35
- "xf" if in_cell_formats => {
36
- let id =
37
- tag.attr("numFmtId").and_then(|id| id.parse::<u32>().ok()).unwrap_or(0);
38
- let custom =
39
- custom.iter().find(|&&(custom, _)| custom == id).map(|&(_, shown)| shown);
40
- shown.push(custom.unwrap_or_else(|| built_in(id)));
41
- }
42
- _ => {}
43
- },
44
- Event::End("cellXfs") => in_cell_formats = false,
45
- _ => {}
46
- }
47
- }
48
- Ok(shown)
17
+ /// A fixed number of decimals, thousands grouped with `,` or not, as a
18
+ /// percentage or not: `0.00`, `#,##0`, `0%`.
19
+ Fixed {
20
+ decimals: u8,
21
+ grouping: bool,
22
+ percent: bool,
23
+ },
49
24
  }
50
25
 
51
26
  /// Excel's built-in number formats (ECMA-376 Part 1, 18.8.30), with the
52
27
  /// East Asian date formats.
53
- const fn built_in(id: u32) -> Shown {
28
+ pub const fn built_in(id: u32) -> Shown {
54
29
  match id {
55
- 9 | 10 => Shown::Percent,
30
+ 2 => fixed(2, false, false),
31
+ 3 | 37 | 38 => fixed(0, true, false),
32
+ 4 | 39 | 40 => fixed(2, true, false),
33
+ 9 => fixed(0, false, true),
34
+ 10 => fixed(2, false, true),
56
35
  14..=17 | 27..=31 | 34..=36 | 50..=58 => Shown::Date,
57
36
  18..=21 | 32 | 33 | 45..=47 => Shown::Time,
58
37
  22 => Shown::DateTime,
@@ -60,14 +39,26 @@ const fn built_in(id: u32) -> Shown {
60
39
  }
61
40
  }
62
41
 
42
+ pub const fn fixed(decimals: u8, grouping: bool, percent: bool) -> Shown {
43
+ Shown::Fixed { decimals, grouping, percent }
44
+ }
45
+
63
46
  /// What a custom format code shows, from its first section: date and time
64
- /// tokens, outside quoted text, escapes and `[...]` (colors, locales;
65
- /// `[h]` is elapsed time).
47
+ /// tokens, decimals, `,` grouping and `%`, outside quoted text, escapes and
48
+ /// `[...]` (colors, locales; `[h]` is elapsed time). Scientific formats
49
+ /// (`0.00E+00`) are shown as General.
66
50
  pub fn classify(code: &str) -> Shown {
67
51
  let (mut date, mut time, mut month, mut percent) = (false, false, false, false);
68
- let mut chars = code.chars();
52
+ let (mut digits, mut point, mut decimals, mut grouping, mut scientific) =
53
+ (false, false, 0_u8, false, false);
54
+ let mut chars = code.chars().peekable();
69
55
  while let Some(ch) = chars.next() {
70
56
  match ch.to_ascii_lowercase() {
57
+ '0' | '#' | '?' if point => decimals = decimals.saturating_add(1),
58
+ '0' | '#' | '?' => digits = true,
59
+ '.' => point = true,
60
+ ',' if digits && !point => grouping = true,
61
+ 'e' if matches!(chars.peek(), Some('+' | '-')) => scientific = true,
71
62
  ';' => break,
72
63
  '"' => {
73
64
  chars.by_ref().find(|&ch| ch == '"');
@@ -90,8 +81,8 @@ pub fn classify(code: &str) -> Shown {
90
81
  (true, true) => Shown::DateTime,
91
82
  (true, false) => Shown::Date,
92
83
  (false, true) => Shown::Time,
93
- (false, false) if percent => Shown::Percent,
94
- (false, false) => Shown::General,
84
+ (false, false) if scientific || !(decimals > 0 || grouping || percent) => Shown::General,
85
+ (false, false) => Shown::Fixed { decimals, grouping, percent },
95
86
  }
96
87
  }
97
88
 
@@ -129,15 +120,48 @@ pub fn push_number(
129
120
  let _ =
130
121
  write!(out, "{:02}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60);
131
122
  }
132
- Shown::Percent => {
133
- push_general(out, number * 100.0, scratch);
134
- out.push('%');
123
+ Shown::Fixed { decimals, grouping, percent } => {
124
+ let number = if percent { number * 100.0 } else { number };
125
+ push_fixed(out, number, decimals, grouping, scratch);
126
+ if percent {
127
+ out.push('%');
128
+ }
135
129
  }
136
130
  _ => push_general(out, number, scratch),
137
131
  }
138
132
  Value::Number(number)
139
133
  }
140
134
 
135
+ /// A number with `decimals` decimals, its thousands grouped with `,`,
136
+ /// rounded half away from zero as Excel does (`12.5` shows `13`, `1.005`
137
+ /// `1.01`): a few units in the last place toward the tie undo the binary
138
+ /// error that makes `1.005` `1.00499...`, which Rust would round down, as it
139
+ /// rounds an exact tie to even.
140
+ fn push_fixed(out: &mut String, number: f64, decimals: u8, grouping: bool, scratch: &mut String) {
141
+ let number = (number.abs() * 4.0 * f64::EPSILON).mul_add(number.signum(), number);
142
+ scratch.clear();
143
+ // Writing to a String cannot fail.
144
+ let _ = write!(scratch, "{number:.*}", usize::from(decimals));
145
+ if !grouping {
146
+ out.push_str(scratch);
147
+ return;
148
+ }
149
+ let (sign, digits) =
150
+ scratch.strip_prefix('-').map_or(("", scratch.as_str()), |rest| ("-", rest));
151
+ let (whole, fraction) = digits.split_once('.').map_or((digits, None), |(w, f)| (w, Some(f)));
152
+ out.push_str(sign);
153
+ for (index, digit) in whole.char_indices() {
154
+ if index > 0 && (whole.len() - index).is_multiple_of(3) {
155
+ out.push(',');
156
+ }
157
+ out.push(digit);
158
+ }
159
+ if let Some(fraction) = fraction {
160
+ out.push('.');
161
+ out.push_str(fraction);
162
+ }
163
+ }
164
+
141
165
  /// A number as Excel's General format shows it: 15 significant digits.
142
166
  pub fn push_general(out: &mut String, number: f64, scratch: &mut String) {
143
167
  #[expect(clippy::cast_possible_truncation, reason = "whole and under 1e15: exact in i64")]
@@ -0,0 +1,356 @@
1
+ //! `.ods` spreadsheets: a zip whose `mimetype` entry names the format and
2
+ //! whose `content.xml` holds the tables. Cells carry their
3
+ //! value (`office:value-type` and its attributes) and the text they show;
4
+ //! repeated rows and columns are expanded only where they hold something.
5
+
6
+ #[cfg(test)]
7
+ mod tests;
8
+
9
+ use std::fmt::Write as _;
10
+ use std::ops::Range;
11
+
12
+ use super::Budget;
13
+ use super::content::{Grid, Note, Sheet, Value};
14
+ use super::numbers::push_general;
15
+ use crate::xlsx::DateTime;
16
+ use crate::xml::reader::{Event, Tag, XmlReader};
17
+ use crate::zip::reader::ZipReader;
18
+ use crate::{Error, Result};
19
+
20
+ const SPREADSHEET: &str = "application/vnd.oasis.opendocument.spreadsheet";
21
+ /// Excel's grid, which repeated rows and columns are cut to.
22
+ const MAX_ROWS: u32 = 1_048_576;
23
+ const MAX_COLUMNS: u32 = 16_384;
24
+
25
+ /// Whether a zip is an ODF file, by its `mimetype` entry: `None` when it is
26
+ /// not one, `Some(true)` for a spreadsheet (or its template).
27
+ ///
28
+ /// # Errors
29
+ /// As reading the entry.
30
+ pub fn spreadsheet(zip: &ZipReader<'_>, budget: &Budget<'_>) -> Result<Option<bool>> {
31
+ let Some(kind) = zip.read("mimetype", budget)? else { return Ok(None) };
32
+ let kind = String::from_utf8_lossy(&kind);
33
+ let kind = kind.trim();
34
+ if !kind.starts_with("application/vnd.oasis.opendocument.") {
35
+ return Ok(None);
36
+ }
37
+ Ok(Some(kind.strip_suffix("-template").unwrap_or(kind) == SPREADSHEET))
38
+ }
39
+
40
+ /// The spreadsheet's tables, in order, with their comments.
41
+ ///
42
+ /// # Errors
43
+ /// [`Error::Malformed`] for a missing or broken `content.xml`, and the
44
+ /// limits' errors.
45
+ pub fn content(zip: &ZipReader<'_>, budget: &Budget<'_>) -> Result<Vec<Sheet>> {
46
+ let content = zip
47
+ .read("content.xml", budget)?
48
+ .ok_or_else(|| Error::malformed("ODS", "no content.xml"))?;
49
+ let text = String::from_utf8(content)
50
+ .map_err(|_| Error::malformed("ODS", "content.xml is not UTF-8"))?;
51
+ let mut reader = Reader::default();
52
+ let mut xml = XmlReader::new(&text, budget.max_depth());
53
+ while let Some(event) = xml.next()? {
54
+ budget.tick()?;
55
+ reader.event(event, budget)?;
56
+ }
57
+ Ok(reader.sheets)
58
+ }
59
+
60
+ /// A cell being read: its value attributes, its repeat, and where its text
61
+ /// starts in the row's buffer.
62
+ #[derive(Default)]
63
+ struct Cell {
64
+ kind: String,
65
+ value: String,
66
+ formula: String,
67
+ repeat: u32,
68
+ paragraphs: usize,
69
+ start: usize,
70
+ }
71
+
72
+ #[derive(Default)]
73
+ struct Reader {
74
+ sheets: Vec<Sheet>,
75
+ hidden_styles: Vec<String>,
76
+ style: Option<String>,
77
+ grid: Grid,
78
+ comments: Vec<Note>,
79
+ row: u32,
80
+ column: u32,
81
+ row_repeat: u32,
82
+ /// The row's cells, their text in one buffer: column, text, value.
83
+ row_text: String,
84
+ row_cells: Vec<(u32, Range<usize>, Value)>,
85
+ cell: Option<Cell>,
86
+ note: Option<(String, String)>,
87
+ in_creator: bool,
88
+ /// Text counts inside paragraphs only, outside drawings and dates of
89
+ /// annotations.
90
+ paragraphs: usize,
91
+ skipping: usize,
92
+ scratch: String,
93
+ }
94
+
95
+ impl Reader {
96
+ fn event(&mut self, event: Event<'_>, budget: &Budget<'_>) -> Result<()> {
97
+ match event {
98
+ Event::Start(tag) => self.start(&tag),
99
+ Event::End(name) => self.end(name, budget)?,
100
+ Event::Text(text) => self.text(&text),
101
+ }
102
+ Ok(())
103
+ }
104
+
105
+ fn start(&mut self, tag: &Tag<'_>) {
106
+ let repeat = |name| tag.attr(name).and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).max(1);
107
+ match tag.name() {
108
+ "style" if tag.attr("family").as_deref() == Some("table") => {
109
+ self.style = tag.attr("name").map(std::borrow::Cow::into_owned);
110
+ }
111
+ "table-properties" if tag.attr("display").as_deref() == Some("false") => {
112
+ if let Some(style) = self.style.take() {
113
+ self.hidden_styles.push(style);
114
+ }
115
+ }
116
+ "table" => {
117
+ let hidden = tag
118
+ .attr("style-name")
119
+ .is_some_and(|style| self.hidden_styles.iter().any(|h| *h == style));
120
+ self.sheets.push(Sheet {
121
+ name: tag.attr("name").map(std::borrow::Cow::into_owned).unwrap_or_default(),
122
+ hidden,
123
+ ..Sheet::default()
124
+ });
125
+ self.row = 0;
126
+ }
127
+ "table-row" => {
128
+ self.column = 0;
129
+ self.row_repeat = repeat("number-rows-repeated");
130
+ }
131
+ "table-cell" | "covered-table-cell" => {
132
+ let attr =
133
+ |name| tag.attr(name).map(std::borrow::Cow::into_owned).unwrap_or_default();
134
+ let kind = attr("value-type");
135
+ let value = match kind.as_str() {
136
+ "date" => attr("date-value"),
137
+ "time" => attr("time-value"),
138
+ "boolean" => attr("boolean-value"),
139
+ _ => attr("value"),
140
+ };
141
+ let repeat = repeat("number-columns-repeated");
142
+ let start = self.row_text.len();
143
+ self.cell = Some(Cell {
144
+ kind,
145
+ value,
146
+ formula: attr("formula"),
147
+ repeat,
148
+ paragraphs: 0,
149
+ start,
150
+ });
151
+ }
152
+ "annotation" => self.note = Some((String::new(), String::new())),
153
+ "creator" if self.note.is_some() => self.in_creator = true,
154
+ "date" if self.note.is_some() => self.skipping += 1,
155
+ "frame" | "custom-shape" | "g" => self.skipping += 1,
156
+ "p" | "h" => {
157
+ // A second paragraph starts on a line of its own.
158
+ let separate = match (&mut self.note, &mut self.cell) {
159
+ (Some((_, text)), _) => !text.is_empty(),
160
+ (None, Some(cell)) => {
161
+ cell.paragraphs += 1;
162
+ cell.paragraphs > 1
163
+ }
164
+ (None, None) => false,
165
+ };
166
+ self.paragraphs += 1;
167
+ if separate {
168
+ self.text("\n");
169
+ }
170
+ }
171
+ "s" => {
172
+ let count = tag
173
+ .attr("c")
174
+ .and_then(|count| count.parse::<usize>().ok())
175
+ .unwrap_or(1)
176
+ .min(1024);
177
+ self.text(&" ".repeat(count));
178
+ }
179
+ "tab" => self.text("\t"),
180
+ "line-break" => self.text("\n"),
181
+ _ => {}
182
+ }
183
+ }
184
+
185
+ fn text(&mut self, text: &str) {
186
+ if self.skipping > 0 {
187
+ return;
188
+ }
189
+ match &mut self.note {
190
+ Some((author, _)) if self.in_creator => author.push_str(text),
191
+ Some((_, note)) if self.paragraphs > 0 => note.push_str(text),
192
+ None if self.cell.is_some() && self.paragraphs > 0 => self.row_text.push_str(text),
193
+ _ => {}
194
+ }
195
+ }
196
+
197
+ fn end(&mut self, name: &str, budget: &Budget<'_>) -> Result<()> {
198
+ match name {
199
+ "style" => self.style = None,
200
+ "creator" => self.in_creator = false,
201
+ "date" if self.note.is_some() => self.skipping = self.skipping.saturating_sub(1),
202
+ "frame" | "custom-shape" | "g" => self.skipping = self.skipping.saturating_sub(1),
203
+ "p" | "h" => self.paragraphs = self.paragraphs.saturating_sub(1),
204
+ "annotation" => {
205
+ if let Some((author, text)) = self.note.take() {
206
+ let author = (!author.trim().is_empty()).then(|| author.trim().into());
207
+ self.comments.push(Note { row: self.row, column: self.column, author, text });
208
+ }
209
+ }
210
+ "table-cell" | "covered-table-cell" => self.end_cell(),
211
+ "table-row" => self.end_row(budget)?,
212
+ "table" => {
213
+ let grid = std::mem::take(&mut self.grid).finish();
214
+ if let Some(sheet) = self.sheets.last_mut() {
215
+ sheet.cells = grid;
216
+ sheet.comments = std::mem::take(&mut self.comments);
217
+ }
218
+ }
219
+ _ => {}
220
+ }
221
+ Ok(())
222
+ }
223
+
224
+ /// The cell's text, typed by its value; repeated across its columns.
225
+ fn end_cell(&mut self) {
226
+ let Some(cell) = self.cell.take() else { return };
227
+ let start = cell.start;
228
+ let shown = self.row_text.split_off(start);
229
+ let value = self.typed(&cell, &shown);
230
+ let end = self.row_text.len();
231
+ if end > start {
232
+ let repeat = cell.repeat.min(MAX_COLUMNS.saturating_sub(self.column));
233
+ for offset in 0..repeat {
234
+ self.row_cells.push((self.column + offset, start..end, value));
235
+ }
236
+ }
237
+ self.column = self.column.saturating_add(cell.repeat);
238
+ }
239
+
240
+ /// Writes the cell's text as shown into the row buffer, returning its
241
+ /// value: numbers keep the text they show, dates and times read as in
242
+ /// XLSX, a formula without a result shows itself.
243
+ fn typed(&mut self, cell: &Cell, shown: &str) -> Value {
244
+ let out = &mut self.row_text;
245
+ match cell.kind.as_str() {
246
+ "float" | "percentage" | "currency" if let Ok(number) = cell.value.parse::<f64>() => {
247
+ if shown.trim().is_empty() {
248
+ push_general(out, number, &mut self.scratch);
249
+ } else {
250
+ out.push_str(shown);
251
+ }
252
+ Value::Number(number)
253
+ }
254
+ "date" if let Some(date) = date(&cell.value) => {
255
+ // Writing to a String cannot fail.
256
+ let _ = write!(out, "{date}");
257
+ Value::Date(date)
258
+ }
259
+ "time" if let Some(seconds) = duration(&cell.value) => {
260
+ let clock = seconds.rem_euclid(86_400);
261
+ // Writing to a String cannot fail.
262
+ let _ = write!(out, "{:02}:{:02}:{:02}", clock / 3600, clock / 60 % 60, clock % 60);
263
+ #[expect(clippy::cast_precision_loss, reason = "seconds of a day")]
264
+ Value::Number(seconds as f64 / 86_400.0)
265
+ }
266
+ "boolean" => {
267
+ let on = cell.value == "true" || cell.value == "1";
268
+ out.push_str(if on { "TRUE" } else { "FALSE" });
269
+ Value::Bool(on)
270
+ }
271
+ "" if !cell.formula.is_empty() && shown.trim().is_empty() => {
272
+ let formula =
273
+ cell.formula.split_once(':').map_or(cell.formula.as_str(), |(_, rest)| rest);
274
+ out.push('=');
275
+ out.push_str(formula.strip_prefix('=').unwrap_or(formula));
276
+ Value::Formula
277
+ }
278
+ _ => {
279
+ out.push_str(shown);
280
+ Value::Text
281
+ }
282
+ }
283
+ }
284
+
285
+ /// Writes the row's cells, once per repeat, and moves past it.
286
+ fn end_row(&mut self, budget: &Budget<'_>) -> Result<()> {
287
+ let repeat = if self.row_cells.is_empty() {
288
+ 0
289
+ } else {
290
+ self.row_repeat.min(MAX_ROWS.saturating_sub(self.row))
291
+ };
292
+ for offset in 0..repeat {
293
+ budget.tick()?;
294
+ budget.inflate(self.row_text.len())?;
295
+ for (column, text, value) in &self.row_cells {
296
+ if text.end > text.start {
297
+ self.grid.buffer().push_str(&self.row_text[text.clone()]);
298
+ self.grid.end_cell(self.row + offset, *column, *value);
299
+ }
300
+ }
301
+ }
302
+ self.row_cells.clear();
303
+ self.row_text.clear();
304
+ self.row = self.row.saturating_add(self.row_repeat);
305
+ Ok(())
306
+ }
307
+ }
308
+
309
+ /// `2023-03-15` or `2023-03-15T09:30:00.5`.
310
+ fn date(value: &str) -> Option<DateTime> {
311
+ let (day, time) = value.split_once('T').unwrap_or((value, ""));
312
+ let mut parts = day.splitn(3, '-').map(str::parse::<u16>);
313
+ let (year, month, day) = (parts.next()?.ok()?, parts.next()?.ok()?, parts.next()?.ok()?);
314
+ let date = DateTime::date(year, u8::try_from(month).ok()?, u8::try_from(day).ok()?).ok()?;
315
+ if time.is_empty() {
316
+ return Some(date);
317
+ }
318
+ let mut parts = time.splitn(3, ':');
319
+ let (hour, minute, second) = (parts.next()?, parts.next()?, parts.next().unwrap_or("0"));
320
+ let seconds: f64 = second.parse().ok()?;
321
+ #[expect(
322
+ clippy::cast_possible_truncation,
323
+ clippy::cast_sign_loss,
324
+ reason = "under 60, checked by `at`"
325
+ )]
326
+ let (whole, nanosecond) = (seconds.trunc() as u8, (seconds.fract() * 1e9).round() as u32);
327
+ date.at(hour.parse().ok()?, minute.parse().ok()?, whole, nanosecond).ok()
328
+ }
329
+
330
+ /// An ISO 8601 duration such as `PT12H30M00S` (or with days), in seconds.
331
+ fn duration(value: &str) -> Option<i64> {
332
+ let (negative, value) = value.strip_prefix('-').map_or((false, value), |rest| (true, rest));
333
+ let value = value.strip_prefix('P')?;
334
+ let (mut seconds, mut number, mut in_time) = (0_f64, String::new(), false);
335
+ for ch in value.chars() {
336
+ match ch {
337
+ 'T' => in_time = true,
338
+ '0'..='9' | '.' => number.push(ch),
339
+ unit => {
340
+ let amount: f64 = number.parse().ok()?;
341
+ number.clear();
342
+ let scale = match (unit, in_time) {
343
+ ('D', false) => 86_400.0,
344
+ ('H', true) => 3_600.0,
345
+ ('M', true) => 60.0,
346
+ ('S', true) => 1.0,
347
+ _ => return None,
348
+ };
349
+ seconds = amount.mul_add(scale, seconds);
350
+ }
351
+ }
352
+ }
353
+ #[expect(clippy::cast_possible_truncation, reason = "rounded seconds")]
354
+ let seconds = seconds.round() as i64;
355
+ Some(if negative { -seconds } else { seconds })
356
+ }