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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/Cargo.lock +4 -3
- data/Cargo.toml +1 -1
- data/README.md +27 -5
- 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 +7 -2
- data/ext/imprimatur_native/core/README.md +6 -1
- data/ext/imprimatur_native/core/src/archive.rs +292 -0
- data/ext/imprimatur_native/core/src/error.rs +1 -1
- data/ext/imprimatur_native/core/src/lib.rs +2 -0
- data/ext/imprimatur_native/core/src/read/content.rs +16 -1
- data/ext/imprimatur_native/core/src/read/json.rs +3 -1
- data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
- data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +4 -3
- data/ext/imprimatur_native/core/src/read/ods.rs +357 -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/xls/strings.rs +156 -0
- data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +1 -2
- data/ext/imprimatur_native/core/src/read.rs +64 -20
- data/ext/imprimatur_native/core/src/test_support.rs +87 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +2 -2
- data/ext/imprimatur_native/ffi/src/archive.rs +121 -0
- data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
- data/ext/imprimatur_native/ffi/src/lib.rs +14 -0
- data/ext/imprimatur_native/ffi/src/read.rs +1 -3
- data/lib/imprimatur/error.rb +4 -3
- data/lib/imprimatur/reader.rb +1 -1
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur/zip.rb +127 -0
- data/lib/imprimatur.rb +3 -2
- metadata +13 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90d5145594d31e77fcde4acfeeecfb69156a5aa449740a54a6435e4f36d34595
|
|
4
|
+
data.tar.gz: '093e50e8ddca274d55841ee9a4b74f21b4061a6f16fc470cfd0985900eb96ec6'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
`.
|
|
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.
|
|
4
|
-
description = "Read PDF, XLSX and DOCX documents as Markdown,
|
|
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
|
|
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 .
|
|
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 .
|
|
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,7 +1,7 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "imprimatur"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "XLSX, PDF and DOCX authoring with its own XML, zip and PDF
|
|
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
|
|
100
|
+
#[error("the document is encrypted with a password")]
|
|
101
101
|
PasswordRequired,
|
|
102
102
|
|
|
103
103
|
#[error(transparent)]
|
|
@@ -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)
|
|
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
|
|
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,
|