imprimatur 0.1.0 → 0.2.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/Cargo.lock +4 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +27 -5
  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 +7 -2
  12. data/ext/imprimatur_native/core/README.md +6 -1
  13. data/ext/imprimatur_native/core/src/archive.rs +292 -0
  14. data/ext/imprimatur_native/core/src/error.rs +1 -1
  15. data/ext/imprimatur_native/core/src/lib.rs +2 -0
  16. data/ext/imprimatur_native/core/src/read/content.rs +16 -1
  17. data/ext/imprimatur_native/core/src/read/json.rs +3 -1
  18. data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
  19. data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +4 -3
  20. data/ext/imprimatur_native/core/src/read/ods.rs +357 -0
  21. data/ext/imprimatur_native/core/src/read/ole.rs +244 -0
  22. data/ext/imprimatur_native/core/src/read/package.rs +0 -5
  23. data/ext/imprimatur_native/core/src/read/xls/strings.rs +156 -0
  24. data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
  25. data/ext/imprimatur_native/core/src/read/xlsx.rs +1 -2
  26. data/ext/imprimatur_native/core/src/read.rs +64 -20
  27. data/ext/imprimatur_native/core/src/test_support.rs +87 -0
  28. data/ext/imprimatur_native/ffi/Cargo.toml +2 -2
  29. data/ext/imprimatur_native/ffi/src/archive.rs +121 -0
  30. data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
  31. data/ext/imprimatur_native/ffi/src/lib.rs +14 -0
  32. data/ext/imprimatur_native/ffi/src/read.rs +1 -3
  33. data/lib/imprimatur/error.rb +4 -3
  34. data/lib/imprimatur/reader.rb +1 -1
  35. data/lib/imprimatur/version.rb +1 -1
  36. data/lib/imprimatur/zip.rb +127 -0
  37. data/lib/imprimatur.rb +3 -2
  38. metadata +13 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86be49aef4927f618e1ae2a92d7211f4c07239631f2b9e280d8a6d44730fbf26
4
- data.tar.gz: 5ead3ff11b3ce1a0de6121138d45e049c316afd82e0e10d55c05cc7d183d2f3a
3
+ metadata.gz: 90d5145594d31e77fcde4acfeeecfb69156a5aa449740a54a6435e4f36d34595
4
+ data.tar.gz: '093e50e8ddca274d55841ee9a4b74f21b4061a6f16fc470cfd0985900eb96ec6'
5
5
  SHA512:
6
- metadata.gz: 1d56f28369bd9373122a014fa597e11a948ef324a9462a8d7e231d39dd9e4472d5951cd36185af54c73686b3adc64d0fa5feae20b8b38125d239ac97da65e5e8
7
- data.tar.gz: a5f77f9af0cbf86147f38af60c27a23b5674869eb480bd831538b440af88dffbaaf125071d568f590176b504104ea624458e8c2f6a571a23f6fb0cb809293fc7
6
+ metadata.gz: 6b9c5b392e20dc7d35c3e10de40f44af8b2664d7df6b7a152e0f3740c58354ed337ee012f675bde18c217e26c0586a3d952bc159970f29871b67a64366728610
7
+ data.tar.gz: bf43e139d43a48ed9910e152d9f98d5542a990da7131a61628bb320be40a7d10bee79992f11544735fddc54253c08b0031f4ab0bada9c397248f9b5c7eaf6f17
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/seuros/imprimatur/compare/v0.1.0...v0.2.0) (2026-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * read legacy XLS and ODS workbooks ([96b86fa](https://github.com/seuros/imprimatur/commit/96b86faab8eb04b80d33885c61b17ebad4f1f63a))
9
+ * zip archive comments and entry listings ([a87f223](https://github.com/seuros/imprimatur/commit/a87f22330859ee4a583943ddbf107571cca9e841))
10
+ * zip archives of any files ([d5ea579](https://github.com/seuros/imprimatur/commit/d5ea579ae1fc80a8ec6a1b213f442d490ededfba))
11
+
3
12
  ## 0.1.0 (2026-10-16)
4
13
 
5
14
 
data/Cargo.lock CHANGED
@@ -230,7 +230,7 @@ dependencies = [
230
230
 
231
231
  [[package]]
232
232
  name = "imprimatur"
233
- version = "0.1.0"
233
+ version = "0.2.0"
234
234
  dependencies = [
235
235
  "aes",
236
236
  "crc32fast",
@@ -238,13 +238,14 @@ dependencies = [
238
238
  "miniz_oxide",
239
239
  "serde_json",
240
240
  "sha2",
241
+ "tempfile",
241
242
  "thiserror",
242
243
  "zip",
243
244
  ]
244
245
 
245
246
  [[package]]
246
247
  name = "imprimatur-cli"
247
- version = "0.1.0"
248
+ version = "0.2.0"
248
249
  dependencies = [
249
250
  "imprimatur",
250
251
  "regex",
@@ -256,7 +257,7 @@ dependencies = [
256
257
 
257
258
  [[package]]
258
259
  name = "imprimatur_native"
259
- version = "0.1.0"
260
+ version = "0.2.0"
260
261
  dependencies = [
261
262
  "imprimatur",
262
263
  "magnus",
data/Cargo.toml CHANGED
@@ -10,7 +10,7 @@ license = "BSD-3-Clause"
10
10
  repository = "https://github.com/seuros/imprimatur"
11
11
 
12
12
  [workspace.dependencies]
13
- imprimatur = { path = "ext/imprimatur_native/core", version = "0.1.0" }
13
+ imprimatur = { path = "ext/imprimatur_native/core", version = "0.2.0" }
14
14
  magnus = { version = "0.9.2", default-features = false }
15
15
  # For rb_thread_call_without_gvl2, which magnus does not wrap; magnus's own
16
16
  # rb-sys, features and all.
data/README.md CHANGED
@@ -197,8 +197,8 @@ PDF, are the next step.
197
197
 
198
198
  ## Reading: documents as Markdown
199
199
 
200
- The other direction: a PDF, XLSX or DOCX printed as Markdown, for search
201
- indexes and language models.
200
+ The other direction: a PDF, a workbook (XLSX, legacy XLS, ODS) or a DOCX
201
+ printed as Markdown, for search indexes and language models.
202
202
 
203
203
  ```ruby
204
204
  Imprimatur.markdown("audit.pdf") # a path
@@ -215,8 +215,8 @@ end
215
215
  - **PDF**: the text layer, placed glyph by glyph (fonts, encodings and
216
216
  ToUnicode maps, forms, rotated pages), in reading order (right-to-left
217
217
  lines too: Hebrew, Arabic, Persian, Urdu, Syriac, Dhivehi, N'Ko, Adlam,
218
- their vowel marks kept on their letters), with headings by font size, hyphenated line breaks rejoined,
219
- presentation forms (`fi`, Arabic contextual forms) as plain letters, and
218
+ their vowel marks kept on their letters), with headings by font size,
219
+ hyphenated line breaks rejoined, presentation forms (`fi`, Arabic contextual forms) as plain letters, and
220
220
  running headers and footers left out. Pages after the first start with
221
221
  `<!-- page N -->`. Encrypted PDFs open when the user password is empty, as
222
222
  it is for documents that only restrict printing or copying (RC4, AES-128,
@@ -224,6 +224,9 @@ end
224
224
  - **XLSX**: each sheet as a table under its name, values as Excel shows them
225
225
  (dates as dates, percentages, 15 significant digits), formulas without a
226
226
  cached result as `=FORMULA`, then the sheet's comments.
227
+ - **XLS** (Excel 97–2003, and Excel 5.0/95 in Western European code pages)
228
+ and **ODS** (LibreOffice, OpenOffice): the same, sheet by sheet; an ODS
229
+ number keeps the text it shows. Legacy `.xls` comments are left out.
227
230
  - **DOCX**: headings (from styles and outline levels), bulleted and numbered
228
231
  lists, bold, italic, links, tables, text boxes; deleted text and field
229
232
  codes left out.
@@ -237,7 +240,7 @@ Documents are untrusted input, so reads are bounded: `max_bytes:` (512 MiB),
237
240
  `Imprimatur::LimitExceeded`. Reading runs without the GVL: other threads and
238
241
  fibers keep running, and `Timeout` or `Thread#raise` cancels it.
239
242
  `Imprimatur::PasswordRequired` and `Imprimatur::UnsupportedDocument` (legacy
240
- `.xls` and `.doc`, `.xlsb`, presentations) are the other errors to expect.
243
+ `.doc`, `.xlsb`, presentations) are the other errors to expect.
241
244
 
242
245
  ### Structure and images
243
246
 
@@ -270,6 +273,25 @@ Imprimatur.images("scan.pdf") do |image|
270
273
  end
271
274
  ```
272
275
 
276
+ ## Zip archives
277
+
278
+ Archives of any files, for what is not a document (an export of an order's
279
+ files, a download to unpack). Entries keep their modification times and
280
+ comments, files are streamed from disk, and archives past 4 GiB use ZIP64;
281
+ reading is bounded per entry. Both run without the GVL.
282
+
283
+ ```ruby
284
+ Imprimatur::Zip.write("export.zip") do |zip|
285
+ zip.add_file("contracts/0contract.pdf", "/tmp/contract.pdf", comment: "Contract")
286
+ zip.add("manifest.json", JSON.generate(manifest))
287
+ end
288
+
289
+ Imprimatur::Zip.open("geolite.zip") do |zip|
290
+ csv = zip.read(zip.glob("**/*-Locations-en.csv").first) # max_bytes: 1 GiB by default
291
+ zip.extract("README.txt", "/tmp/readme.txt")
292
+ end
293
+ ```
294
+
273
295
  ## Command line
274
296
 
275
297
  The same reader as a static binary, for scripts and agents that would
@@ -1,7 +1,7 @@
1
1
  [package]
2
2
  name = "imprimatur-cli"
3
- version = "0.1.0"
4
- description = "Read PDF, XLSX and DOCX documents as Markdown, from the command line."
3
+ version = "0.2.0"
4
+ description = "Read PDF, XLSX, XLS, ODS and DOCX documents as Markdown or JSON, search them, extract their images."
5
5
  keywords = ["pdf", "xlsx", "docx", "markdown", "cli"]
6
6
  categories = ["command-line-utilities", "text-processing"]
7
7
  readme = "README.md"
@@ -127,7 +127,7 @@ fn lines<'c>(content: &'c Content, mut each: impl FnMut(Location<'c>, &str) -> b
127
127
  }
128
128
  }
129
129
  }
130
- Content::Xlsx(sheets) => {
130
+ Content::Xlsx(sheets) | Content::Ods(sheets) | Content::Xls(sheets) => {
131
131
  for sheet in sheets {
132
132
  for cell in sheet.cells.cells() {
133
133
  let reference = cell.reference();
@@ -16,8 +16,8 @@ use crate::app::{self, App, Out, Status};
16
16
  /// PDF: the text layer, placed glyph by glyph, with headings by font size,
17
17
  /// running headers and footers left out, and pages after the first marked
18
18
  /// `<!-- page N -->`. Encrypted PDFs open when their user password is
19
- /// empty. XLSX: each sheet as a table, values as Excel shows them, then its
20
- /// comments. DOCX: headings, lists, emphasis, links and tables.
19
+ /// empty. XLSX, XLS and ODS: each sheet as a table, values as shown, then
20
+ /// its comments. DOCX: headings, lists, emphasis, links and tables.
21
21
  ///
22
22
  /// With several documents, each is introduced by `<!-- file: PATH -->`. A
23
23
  /// document that fails is reported on standard error and the others are
@@ -22,11 +22,11 @@ pub const EXIT_STATUS: &str = "Exit status:
22
22
  1: a document is damaged, or a file could not be read or written
23
23
  2: the command line is wrong
24
24
  3: a PDF has no text layer: its pages are scans, which need OCR
25
- 4: a PDF is encrypted with a password
26
- 5: not a PDF, XLSX or DOCX (legacy .xls and .doc, .xlsb, presentations)
25
+ 4: a PDF or .xls is encrypted with a password
26
+ 5: not a PDF, XLSX, XLS, ODS or DOCX (legacy .doc, .xlsb, presentations)
27
27
  6: a limit was hit: size, decompressed size, nesting or time";
28
28
 
29
- /// Read PDF, XLSX and DOCX documents: as Markdown or JSON, searched, or their images
29
+ /// Read PDF, XLSX, XLS, ODS and DOCX documents: as Markdown or JSON, searched, or their images
30
30
  ///
31
31
  /// The format is told by a document's content, not its name. Documents are
32
32
  /// untrusted input: reading is bounded in size, decompressed size, nesting
@@ -45,8 +45,8 @@ pub const EXIT_STATUS: &str = "Exit status:
45
45
  exit_code(1, "a document is damaged, or a file could not be read or written"),
46
46
  exit_code(2, "the command line is wrong"),
47
47
  exit_code(3, "a PDF has no text layer: its pages are scans, which need OCR"),
48
- exit_code(4, "a PDF is encrypted with a password"),
49
- exit_code(5, "not a PDF, XLSX or DOCX: legacy .xls and .doc, .xlsb, presentations"),
48
+ exit_code(4, "a PDF or .xls is encrypted with a password"),
49
+ exit_code(5, "not a PDF, XLSX, XLS, ODS or DOCX: legacy .doc, .xlsb, presentations"),
50
50
  exit_code(6, "a limit was hit: size, decompressed size, nesting or time"),
51
51
  after_long_help = EXIT_STATUS
52
52
  )]
@@ -1,4 +1,4 @@
1
- //! `imprimatur`: documents in, Markdown out. Reads PDF, XLSX and DOCX with
1
+ //! `imprimatur`: documents in, Markdown out. Reads PDF, XLSX, XLS, ODS and DOCX with
2
2
  //! the imprimatur crate; see `imprimatur --help`.
3
3
 
4
4
  mod app;
@@ -1,7 +1,7 @@
1
1
  [package]
2
2
  name = "imprimatur"
3
- version = "0.1.0"
4
- description = "XLSX, PDF and DOCX authoring with its own XML, zip and PDF writers."
3
+ version = "0.2.0"
4
+ description = "XLSX, PDF and DOCX authoring, and PDF, XLSX, XLS, ODS and DOCX reading, with its own XML, zip and PDF code."
5
5
  keywords = ["xlsx", "excel", "pdf", "docx", "spreadsheet"]
6
6
  categories = ["encoding", "text-processing"]
7
7
  readme = "README.md"
@@ -23,11 +23,16 @@ miniz_oxide = "0.9.1"
23
23
  serde_json = { version = "1.0.154", optional = true }
24
24
  sha2 = "0.11.1"
25
25
  thiserror = "2.0.21"
26
+ zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2-zlib-rs"], optional = true }
26
27
 
27
28
  [features]
28
29
  # `read::json`: content and images as JSON, the schema the command line and
29
30
  # the Ruby binding share.
30
31
  json = ["dep:serde_json"]
32
+ # `archive`: zip archives of any files (written with their times and
33
+ # comments, ZIP64 when large; read within limits), through the `zip` crate.
34
+ archive = ["dep:zip"]
31
35
 
32
36
  [dev-dependencies]
37
+ tempfile = "3"
33
38
  zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
@@ -56,7 +56,7 @@ text over 32,767 characters, formulas over 8,192, NaN and infinity, font
56
56
  sizes Excel cannot set, inline validation lists over 255 characters, and
57
57
  validations whose ranges overlap. A rejected row writes nothing.
58
58
 
59
- Reading goes the other way, any PDF, XLSX or DOCX to Markdown, within
59
+ Reading goes the other way, any PDF, XLSX, XLS, ODS or DOCX to Markdown, within
60
60
  limits (input size, decompressed size, nesting, time, a cancel flag):
61
61
 
62
62
  ```rust
@@ -100,6 +100,11 @@ assert!(found.is_empty(), "no pictures in this one");
100
100
  Dependencies: `miniz_oxide`, `crc32fast` and `thiserror`; reading encrypted
101
101
  PDFs adds `md-5`, `sha2` and `aes`.
102
102
 
103
+ Two features add what the core does not need: `json` (`read::json`, the
104
+ schema the command line and the Ruby gem print, through `serde_json`) and
105
+ `archive` (`archive::{Writer, Archive}`: zip archives of any files, with
106
+ times, comments and ZIP64, through the `zip` crate).
107
+
103
108
  ## License
104
109
 
105
110
  BSD 3-Clause
@@ -0,0 +1,292 @@
1
+ //! Zip archives of any files, for what is not a document.
2
+ //!
3
+ //! Entries are written from disk (streamed) or from memory, with their
4
+ //! modification times and comments, ZIP64 when an entry is large; they are
5
+ //! read one by one, each within a size limit. Behind the `archive` feature,
6
+ //! which brings in the `zip` crate; documents' own zips (XLSX, DOCX) never
7
+ //! need it.
8
+ //!
9
+ //! ```
10
+ //! use std::io::Cursor;
11
+ //! use imprimatur::archive::{Archive, Writer};
12
+ //!
13
+ //! let mut writer = Writer::new(Cursor::new(Vec::new()));
14
+ //! writer.add("notes/readme.txt", b"hello", Some("first"))?;
15
+ //! let bytes = writer.finish()?.into_inner();
16
+ //!
17
+ //! let mut archive = Archive::new(Cursor::new(bytes))?;
18
+ //! assert_eq!(archive.names(), ["notes/readme.txt"]);
19
+ //! assert_eq!(archive.read("notes/readme.txt", 1024)?.as_deref(), Some(&b"hello"[..]));
20
+ //! # Ok::<(), imprimatur::Error>(())
21
+ //! ```
22
+
23
+ #[cfg(test)]
24
+ mod tests;
25
+
26
+ use std::fs::File;
27
+ use std::io::{self, Read, Seek, Write};
28
+ use std::path::Path;
29
+ use std::time::{SystemTime, UNIX_EPOCH};
30
+
31
+ use ::zip::result::ZipError;
32
+ use ::zip::write::FullFileOptions;
33
+ use ::zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
34
+
35
+ use crate::{Error, Result};
36
+
37
+ /// An archive being written; [`finish`](Self::finish) writes its directory.
38
+ pub struct Writer<W: Write + Seek> {
39
+ zip: ZipWriter<W>,
40
+ }
41
+
42
+ impl Writer<File> {
43
+ /// A new archive at `path`, replacing any file there.
44
+ ///
45
+ /// # Errors
46
+ /// [`Error::Io`] when the file cannot be created.
47
+ pub fn create(path: impl AsRef<Path>) -> Result<Self> {
48
+ Ok(Self::new(File::create(path)?))
49
+ }
50
+ }
51
+
52
+ impl<W: Write + Seek> Writer<W> {
53
+ pub fn new(sink: W) -> Self {
54
+ Self { zip: ZipWriter::new(sink) }
55
+ }
56
+
57
+ /// Adds the file at `source` as `name`, streamed from disk, with its
58
+ /// modification time.
59
+ ///
60
+ /// # Errors
61
+ /// [`Error::Io`] when the file cannot be read or the archive written.
62
+ pub fn add_file(
63
+ &mut self,
64
+ name: &str,
65
+ source: impl AsRef<Path>,
66
+ comment: Option<&str>,
67
+ ) -> Result<()> {
68
+ let mut file = File::open(source)?;
69
+ let metadata = file.metadata()?;
70
+ self.zip
71
+ .start_file(name, options(metadata.modified().ok(), metadata.len(), comment))
72
+ .map_err(error)?;
73
+ io::copy(&mut file, &mut self.zip)?;
74
+ Ok(())
75
+ }
76
+
77
+ /// Adds `data` as `name`, dated now.
78
+ ///
79
+ /// # Errors
80
+ /// [`Error::Io`] when the archive cannot be written.
81
+ pub fn add(&mut self, name: &str, data: &[u8], comment: Option<&str>) -> Result<()> {
82
+ let size = u64::try_from(data.len()).unwrap_or(u64::MAX);
83
+ self.zip
84
+ .start_file(name, options(Some(SystemTime::now()), size, comment))
85
+ .map_err(error)?;
86
+ self.zip.write_all(data)?;
87
+ Ok(())
88
+ }
89
+
90
+ /// The archive's own comment, shown by most zip tools.
91
+ ///
92
+ /// # Errors
93
+ /// [`Error::Malformed`] for a comment over 65,535 bytes.
94
+ pub fn set_comment(&mut self, comment: &str) -> Result<()> {
95
+ self.zip.set_comment(comment).map_err(error)
96
+ }
97
+
98
+ /// Writes the central directory and gives the sink back.
99
+ ///
100
+ /// # Errors
101
+ /// [`Error::Io`] when it cannot be written.
102
+ pub fn finish(self) -> Result<W> {
103
+ self.zip.finish().map_err(error)
104
+ }
105
+ }
106
+
107
+ /// How an entry is written: deflated, dated, ZIP64 when it needs to be.
108
+ fn options(
109
+ modified: Option<SystemTime>,
110
+ size: u64,
111
+ comment: Option<&str>,
112
+ ) -> FullFileOptions<'static, 'static> {
113
+ let options = FullFileOptions::default()
114
+ .compression_method(CompressionMethod::Deflated)
115
+ .last_modified_time(dos_time(modified))
116
+ .large_file(size >= u64::from(u32::MAX));
117
+ match comment {
118
+ Some(comment) => options.with_file_comment(comment),
119
+ None => options,
120
+ }
121
+ }
122
+
123
+ /// A time as a zip entry's (UTC, two-second steps, 1980 to 2107); the
124
+ /// format's epoch outside that range.
125
+ fn dos_time(time: Option<SystemTime>) -> DateTime {
126
+ let Some(seconds) =
127
+ time.and_then(|time| time.duration_since(UNIX_EPOCH).ok()).map(|since| since.as_secs())
128
+ else {
129
+ return DateTime::default();
130
+ };
131
+ let days = i64::try_from(seconds / 86_400).unwrap_or(i64::MAX);
132
+ let (year, month, day) = civil(days);
133
+ let clock = seconds % 86_400;
134
+ let part = |value: u64| u8::try_from(value).unwrap_or(0);
135
+ u16::try_from(year)
136
+ .ok()
137
+ .and_then(|year| {
138
+ DateTime::from_date_and_time(
139
+ year,
140
+ month,
141
+ day,
142
+ part(clock / 3600),
143
+ part(clock / 60 % 60),
144
+ part(clock % 60),
145
+ )
146
+ .ok()
147
+ })
148
+ .unwrap_or_default()
149
+ }
150
+
151
+ /// Days since 1970-01-01 as a proleptic Gregorian date (Howard Hinnant's
152
+ /// `civil_from_days`).
153
+ fn civil(days: i64) -> (i64, u8, u8) {
154
+ let days = days + 719_468;
155
+ let era = days.div_euclid(146_097);
156
+ let of_era = days.rem_euclid(146_097);
157
+ let year_of_era = (of_era - of_era / 1460 + of_era / 36_524 - of_era / 146_096) / 365;
158
+ let of_year = of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
159
+ let shifted = (5 * of_year + 2) / 153;
160
+ let day = u8::try_from(of_year - (153 * shifted + 2) / 5 + 1).unwrap_or(1);
161
+ let month = u8::try_from(if shifted < 10 { shifted + 3 } else { shifted - 9 }).unwrap_or(1);
162
+ let year = year_of_era + era * 400 + i64::from(month <= 2);
163
+ (year, month, day)
164
+ }
165
+
166
+ /// What the archive's directory says of an entry.
167
+ #[derive(Debug, Clone, PartialEq, Eq)]
168
+ pub struct Entry {
169
+ pub name: String,
170
+ /// Its size once decompressed, as the directory states it.
171
+ pub size: u64,
172
+ pub compressed_size: u64,
173
+ pub crc32: u32,
174
+ pub comment: String,
175
+ }
176
+
177
+ /// An archive being read.
178
+ pub struct Archive<R: Read + Seek> {
179
+ zip: ZipArchive<R>,
180
+ }
181
+
182
+ impl Archive<File> {
183
+ /// The archive at `path`.
184
+ ///
185
+ /// # Errors
186
+ /// [`Error::Io`] when it cannot be opened, [`Error::Malformed`] when it
187
+ /// is no zip.
188
+ pub fn open(path: impl AsRef<Path>) -> Result<Self> {
189
+ Self::new(File::open(path)?)
190
+ }
191
+ }
192
+
193
+ impl<R: Read + Seek> Archive<R> {
194
+ /// # Errors
195
+ /// [`Error::Malformed`] when `source` is no zip.
196
+ pub fn new(source: R) -> Result<Self> {
197
+ Ok(Self { zip: ZipArchive::new(source).map_err(error)? })
198
+ }
199
+
200
+ /// The entries' names, in the archive's order.
201
+ #[must_use]
202
+ pub fn names(&self) -> Vec<String> {
203
+ self.zip
204
+ .file_names()
205
+ .filter_map(std::result::Result::ok)
206
+ .map(std::borrow::Cow::into_owned)
207
+ .collect()
208
+ }
209
+
210
+ /// Every entry's name, sizes, checksum and comment, in the archive's
211
+ /// order, without decompressing anything.
212
+ ///
213
+ /// # Errors
214
+ /// [`Error::Malformed`] for a damaged directory.
215
+ pub fn entries(&mut self) -> Result<Vec<Entry>> {
216
+ (0..self.zip.len())
217
+ .map(|index| {
218
+ let entry = self.zip.by_index_raw(index).map_err(error)?;
219
+ Ok(Entry {
220
+ name: entry.name().map_err(error)?.into_owned(),
221
+ size: entry.size(),
222
+ compressed_size: entry.compressed_size(),
223
+ crc32: entry.crc32(),
224
+ comment: entry.comment().to_owned(),
225
+ })
226
+ })
227
+ .collect()
228
+ }
229
+
230
+ /// The entry `name`'s bytes, `None` when there is none.
231
+ ///
232
+ /// # Errors
233
+ /// [`Error::LimitExceeded`] past `limit` bytes, [`Error::PasswordRequired`]
234
+ /// for an encrypted entry, [`Error::Malformed`] for a damaged one.
235
+ pub fn read(&mut self, name: &str, limit: u64) -> Result<Option<Vec<u8>>> {
236
+ let mut out = Vec::new();
237
+ self.copy(name, &mut out, limit).map(|found| found.then_some(out))
238
+ }
239
+
240
+ /// Writes the entry `name` to the file at `path`; whether there was one.
241
+ ///
242
+ /// # Errors
243
+ /// As [`read`](Self::read), and [`Error::Io`] for the file.
244
+ pub fn extract(&mut self, name: &str, path: impl AsRef<Path>, limit: u64) -> Result<bool> {
245
+ if !self.names().iter().any(|entry| entry == name) {
246
+ return Ok(false);
247
+ }
248
+ let mut file = File::create(path)?;
249
+ self.copy(name, &mut file, limit)
250
+ }
251
+
252
+ /// Copies an entry's bytes to `out`, at most `limit` of them.
253
+ fn copy(&mut self, name: &str, out: &mut impl Write, limit: u64) -> Result<bool> {
254
+ let entry = match self.zip.by_name(name) {
255
+ Ok(entry) => entry,
256
+ Err(ZipError::FileNotFound) => return Ok(false),
257
+ Err(other) => return Err(error(other)),
258
+ };
259
+ // One byte past the limit tells a full entry from a cut one.
260
+ let copied = io::copy(&mut entry.take(limit.saturating_add(1)), out)?;
261
+ if copied > limit {
262
+ return Err(Error::LimitExceeded("decompressed size"));
263
+ }
264
+ Ok(true)
265
+ }
266
+ }
267
+
268
+ impl<W: Write + Seek> std::fmt::Debug for Writer<W> {
269
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270
+ formatter.debug_struct("Writer").finish_non_exhaustive()
271
+ }
272
+ }
273
+
274
+ impl<R: Read + Seek> std::fmt::Debug for Archive<R> {
275
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276
+ formatter.debug_struct("Archive").field("entries", &self.zip.len()).finish()
277
+ }
278
+ }
279
+
280
+ fn error(error: ZipError) -> Error {
281
+ match error {
282
+ ZipError::Io(error) => Error::Io(error),
283
+ ZipError::InvalidPassword => Error::PasswordRequired,
284
+ ZipError::UnsupportedArchive(reason) if reason.contains("assword") => {
285
+ Error::PasswordRequired
286
+ }
287
+ ZipError::UnsupportedArchive(reason) => {
288
+ Error::UnsupportedDocument(format!("a zip entry: {reason}"))
289
+ }
290
+ other => Error::malformed("zip", other.to_string()),
291
+ }
292
+ }
@@ -97,7 +97,7 @@ pub enum Error {
97
97
  #[error("the PDF has no text layer: its pages are images, which need OCR")]
98
98
  NoTextLayer,
99
99
 
100
- #[error("the PDF is encrypted with a password")]
100
+ #[error("the document is encrypted with a password")]
101
101
  PasswordRequired,
102
102
 
103
103
  #[error(transparent)]
@@ -18,6 +18,8 @@
18
18
  //! # Ok::<(), imprimatur::Error>(())
19
19
  //! ```
20
20
 
21
+ #[cfg(feature = "archive")]
22
+ pub mod archive;
21
23
  pub mod doc;
22
24
  mod docx;
23
25
  mod error;
@@ -13,13 +13,17 @@ use super::span;
13
13
  use crate::xlsx::push_cell_reference;
14
14
  use crate::xlsx::{DateTime, cell_reference};
15
15
 
16
- /// A document's content, by format.
16
+ /// A document's content, by format. Every workbook format holds sheets.
17
17
  #[derive(Debug, Clone, PartialEq)]
18
18
  #[non_exhaustive]
19
19
  pub enum Content {
20
20
  Pdf(Vec<Page>),
21
21
  Xlsx(Vec<Sheet>),
22
22
  Docx(Vec<Block>),
23
+ /// An `.ods` spreadsheet.
24
+ Ods(Vec<Sheet>),
25
+ /// A legacy Excel workbook (Excel 97 to 2003).
26
+ Xls(Vec<Sheet>),
23
27
  }
24
28
 
25
29
  impl Content {
@@ -30,6 +34,17 @@ impl Content {
30
34
  Self::Pdf(_) => super::Format::Pdf,
31
35
  Self::Xlsx(_) => super::Format::Xlsx,
32
36
  Self::Docx(_) => super::Format::Docx,
37
+ Self::Ods(_) => super::Format::Ods,
38
+ Self::Xls(_) => super::Format::Xls,
39
+ }
40
+ }
41
+
42
+ /// A workbook's sheets, whatever its format; `None` for documents.
43
+ #[must_use]
44
+ pub fn sheets(&self) -> Option<&[Sheet]> {
45
+ match self {
46
+ Self::Xlsx(sheets) | Self::Ods(sheets) | Self::Xls(sheets) => Some(sheets),
47
+ Self::Pdf(_) | Self::Docx(_) => None,
33
48
  }
34
49
  }
35
50
  }
@@ -18,7 +18,9 @@ pub fn content(content: &Content) -> Map<String, Json> {
18
18
  object.insert("format".into(), content.format().as_str().into());
19
19
  match content {
20
20
  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()),
21
+ Content::Xlsx(sheets) | Content::Ods(sheets) | Content::Xls(sheets) => {
22
+ object.insert("sheets".into(), sheets.iter().map(sheet).collect())
23
+ }
22
24
  Content::Docx(blocks) => object.insert("blocks".into(), blocks.iter().map(block).collect()),
23
25
  };
24
26
  object
@@ -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,5 +1,6 @@
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
 
@@ -50,7 +51,7 @@ pub fn styles(text: &str, budget: &Budget<'_>) -> Result<Vec<Shown>> {
50
51
 
51
52
  /// Excel's built-in number formats (ECMA-376 Part 1, 18.8.30), with the
52
53
  /// East Asian date formats.
53
- const fn built_in(id: u32) -> Shown {
54
+ pub const fn built_in(id: u32) -> Shown {
54
55
  match id {
55
56
  9 | 10 => Shown::Percent,
56
57
  14..=17 | 27..=31 | 34..=36 | 50..=58 => Shown::Date,