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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/Cargo.lock +124 -3
- data/Cargo.toml +1 -1
- data/README.md +123 -23
- data/ext/imprimatur_native/cli/Cargo.toml +2 -2
- data/ext/imprimatur_native/cli/src/cli/grep.rs +1 -1
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +2 -2
- data/ext/imprimatur_native/cli/src/cli.rs +5 -5
- data/ext/imprimatur_native/cli/src/main.rs +1 -1
- data/ext/imprimatur_native/core/Cargo.toml +12 -2
- data/ext/imprimatur_native/core/README.md +15 -1
- data/ext/imprimatur_native/core/src/archive.rs +279 -0
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
- data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
- data/ext/imprimatur_native/core/src/doc.rs +86 -2
- data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
- data/ext/imprimatur_native/core/src/docx.rs +91 -28
- data/ext/imprimatur_native/core/src/error.rs +13 -1
- data/ext/imprimatur_native/core/src/font.rs +229 -0
- data/ext/imprimatur_native/core/src/lib.rs +5 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
- data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
- data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
- data/ext/imprimatur_native/core/src/pdf.rs +31 -3
- data/ext/imprimatur_native/core/src/read/content.rs +91 -3
- data/ext/imprimatur_native/core/src/read/json.rs +96 -7
- data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
- data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +69 -45
- data/ext/imprimatur_native/core/src/read/ods.rs +356 -0
- data/ext/imprimatur_native/core/src/read/ole.rs +244 -0
- data/ext/imprimatur_native/core/src/read/package.rs +0 -5
- data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
- data/ext/imprimatur_native/core/src/read/xls/strings.rs +156 -0
- data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
- data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -20
- data/ext/imprimatur_native/core/src/read.rs +68 -21
- data/ext/imprimatur_native/core/src/script.rs +116 -0
- data/ext/imprimatur_native/core/src/style.rs +21 -0
- data/ext/imprimatur_native/core/src/test_support.rs +90 -2
- data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
- data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
- data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
- data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
- data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
- data/ext/imprimatur_native/ffi/src/archive.rs +119 -0
- data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
- data/ext/imprimatur_native/ffi/src/document.rs +48 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +52 -1
- data/ext/imprimatur_native/ffi/src/read.rs +9 -14
- data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
- data/lib/imprimatur/document.rb +30 -6
- data/lib/imprimatur/error.rb +4 -3
- data/lib/imprimatur/reader.rb +3 -3
- data/lib/imprimatur/spreadsheet.rb +151 -0
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur/xlsx.rb +25 -4
- data/lib/imprimatur/zip.rb +127 -0
- data/lib/imprimatur.rb +18 -5
- 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::{
|
|
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)
|
|
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
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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::
|
|
94
|
-
(false, false) => Shown::
|
|
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::
|
|
133
|
-
|
|
134
|
-
out
|
|
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
|
+
}
|