imprimatur 0.1.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 (98) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Cargo.lock +835 -0
  4. data/Cargo.toml +44 -0
  5. data/LICENSE.txt +29 -0
  6. data/README.md +314 -0
  7. data/ext/imprimatur_native/cli/Cargo.toml +30 -0
  8. data/ext/imprimatur_native/cli/src/app.rs +183 -0
  9. data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
  10. data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
  11. data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
  12. data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
  13. data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
  14. data/ext/imprimatur_native/cli/src/cli.rs +71 -0
  15. data/ext/imprimatur_native/cli/src/main.rs +15 -0
  16. data/ext/imprimatur_native/core/Cargo.toml +33 -0
  17. data/ext/imprimatur_native/core/LICENSE +29 -0
  18. data/ext/imprimatur_native/core/README.md +105 -0
  19. data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
  20. data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
  21. data/ext/imprimatur_native/core/src/doc.rs +210 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +343 -0
  23. data/ext/imprimatur_native/core/src/error.rs +113 -0
  24. data/ext/imprimatur_native/core/src/lib.rs +38 -0
  25. data/ext/imprimatur_native/core/src/opc.rs +51 -0
  26. data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
  27. data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
  28. data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
  29. data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
  30. data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
  31. data/ext/imprimatur_native/core/src/pdf.rs +84 -0
  32. data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
  33. data/ext/imprimatur_native/core/src/read/content.rs +266 -0
  34. data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
  35. data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
  36. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
  37. data/ext/imprimatur_native/core/src/read/image.rs +233 -0
  38. data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
  39. data/ext/imprimatur_native/core/src/read/json.rs +172 -0
  40. data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
  41. data/ext/imprimatur_native/core/src/read/package.rs +191 -0
  42. data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
  43. data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
  44. data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
  45. data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
  46. data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
  47. data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
  48. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
  49. data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
  50. data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
  51. data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
  52. data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
  53. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
  54. data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
  55. data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
  56. data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
  57. data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
  58. data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
  59. data/ext/imprimatur_native/core/src/read/span.rs +22 -0
  60. data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
  61. data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
  62. data/ext/imprimatur_native/core/src/read.rs +145 -0
  63. data/ext/imprimatur_native/core/src/style.rs +82 -0
  64. data/ext/imprimatur_native/core/src/test_support.rs +163 -0
  65. data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
  66. data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
  67. data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
  68. data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
  69. data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
  70. data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
  71. data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
  72. data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
  73. data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
  74. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
  75. data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
  76. data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
  77. data/ext/imprimatur_native/core/src/xml.rs +95 -0
  78. data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
  79. data/ext/imprimatur_native/core/src/zip.rs +161 -0
  80. data/ext/imprimatur_native/extconf.rb +45 -0
  81. data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
  82. data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
  83. data/ext/imprimatur_native/ffi/src/document.rs +138 -0
  84. data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
  85. data/ext/imprimatur_native/ffi/src/read.rs +182 -0
  86. data/ext/imprimatur_native/ffi/src/style.rs +85 -0
  87. data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
  88. data/lib/imprimatur/document.rb +127 -0
  89. data/lib/imprimatur/error.rb +22 -0
  90. data/lib/imprimatur/formula.rb +13 -0
  91. data/lib/imprimatur/native.rb +17 -0
  92. data/lib/imprimatur/reader.rb +59 -0
  93. data/lib/imprimatur/style.rb +31 -0
  94. data/lib/imprimatur/validation.rb +35 -0
  95. data/lib/imprimatur/version.rb +5 -0
  96. data/lib/imprimatur/xlsx.rb +183 -0
  97. data/lib/imprimatur.rb +111 -0
  98. metadata +157 -0
@@ -0,0 +1,340 @@
1
+ //! XLSX: each sheet's cells, with their values and their text as Excel
2
+ //! shows it (dates as dates, percentages as percentages, 15 significant
3
+ //! digits), formulas without a cached result as `=FORMULA`, then the sheet's
4
+ //! comments.
5
+
6
+ mod numbers;
7
+ #[cfg(test)]
8
+ mod tests;
9
+
10
+ use std::sync::Arc;
11
+
12
+ use super::Budget;
13
+ use super::content::{Grid, Note, Sheet, Value};
14
+ use super::package::Package;
15
+ use crate::Result;
16
+ use crate::xlsx::parse_cell_reference;
17
+ use crate::xlsx::text::unescape as unescape_xstring;
18
+ use crate::xml::reader::{Event, XmlReader};
19
+ use numbers::{Shown, push_number, styles};
20
+
21
+ /// A cell's `t` attribute.
22
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23
+ enum Kind {
24
+ Number,
25
+ Shared,
26
+ Inline,
27
+ Text,
28
+ Bool,
29
+ Error,
30
+ Iso,
31
+ }
32
+
33
+ /// A sheet as the workbook lists it.
34
+ struct Listed {
35
+ name: String,
36
+ part: String,
37
+ hidden: bool,
38
+ }
39
+
40
+ /// The shared string table: every string in one buffer.
41
+ #[derive(Default)]
42
+ struct Strings {
43
+ text: String,
44
+ ends: Vec<usize>,
45
+ }
46
+
47
+ impl Strings {
48
+ fn get(&self, index: usize) -> Option<&str> {
49
+ let end = *self.ends.get(index)?;
50
+ let start = index.checked_sub(1).map_or(0, |previous| self.ends[previous]);
51
+ Some(&self.text[start..end])
52
+ }
53
+ }
54
+
55
+ /// Where a cell's text events go.
56
+ #[derive(Clone, Copy, PartialEq, Eq)]
57
+ enum Into {
58
+ Nowhere,
59
+ Value,
60
+ Formula,
61
+ Inline,
62
+ }
63
+
64
+ pub fn content(package: &Package<'_>, budget: &Budget<'_>) -> Result<Vec<Sheet>> {
65
+ let main = package.main();
66
+ let text = package.main_text()?;
67
+ let relationships = package.relationships(main)?;
68
+ let (listed, date1904) = workbook(&text, &relationships, budget)?;
69
+ let strings = package
70
+ .parse_related(&relationships, "sharedStrings", |text| shared_strings(text, budget))?;
71
+ let shown = package.parse_related(&relationships, "styles", |text| styles(text, budget))?;
72
+ let book = Book { strings: &strings, shown: &shown, date1904 };
73
+ let mut sheets = Vec::with_capacity(listed.len());
74
+ let mut scratch = String::new();
75
+ for sheet in listed {
76
+ budget.check()?;
77
+ let Some(text) = package.text(&sheet.part)? else { continue };
78
+ let cells = cells(&text, &book, &mut scratch, budget)?;
79
+ let comments = match package.related(&sheet.part, "comments")? {
80
+ Some(part) => package
81
+ .text(&part)?
82
+ .map_or_else(|| Ok(Vec::new()), |text| comments(&text, budget))?,
83
+ None => Vec::new(),
84
+ };
85
+ sheets.push(Sheet { name: sheet.name, hidden: sheet.hidden, cells, comments });
86
+ }
87
+ Ok(sheets)
88
+ }
89
+
90
+ /// The sheets in order, with their parts, and whether dates count from 1904.
91
+ fn workbook(
92
+ text: &str,
93
+ relationships: &[super::package::Relationship],
94
+ budget: &Budget<'_>,
95
+ ) -> Result<(Vec<Listed>, bool)> {
96
+ let mut xml = XmlReader::new(text, budget.max_depth());
97
+ let (mut sheets, mut date1904) = (Vec::new(), false);
98
+ while let Some(event) = xml.next()? {
99
+ let Event::Start(tag) = event else { continue };
100
+ match tag.name() {
101
+ "workbookPr" => {
102
+ date1904 = tag.attr("date1904").is_some_and(|on| on == "1" || on == "true");
103
+ }
104
+ "sheet" => {
105
+ let (Some(name), Some(id)) = (tag.attr("name"), tag.attr("id")) else { continue };
106
+ let Some(relationship) = relationships.iter().find(|r| r.id == id) else {
107
+ continue;
108
+ };
109
+ let hidden = tag.attr("state").is_some_and(|state| state != "visible");
110
+ sheets.push(Listed {
111
+ name: name.into_owned(),
112
+ part: relationship.target.clone(),
113
+ hidden,
114
+ });
115
+ }
116
+ _ => {}
117
+ }
118
+ }
119
+ Ok((sheets, date1904))
120
+ }
121
+
122
+ fn shared_strings(text: &str, budget: &Budget<'_>) -> Result<Strings> {
123
+ let mut xml = XmlReader::new(text, budget.max_depth());
124
+ let mut strings = Strings::default();
125
+ // Phonetic runs (rPh) annotate East Asian text; they are not its text.
126
+ let (mut in_text, mut phonetic) = (false, 0_usize);
127
+ while let Some(event) = xml.next()? {
128
+ budget.tick()?;
129
+ match event {
130
+ Event::Start(tag) => match tag.name() {
131
+ "t" => in_text = phonetic == 0,
132
+ "rPh" => phonetic += 1,
133
+ _ => {}
134
+ },
135
+ Event::Text(text) if in_text => strings.text.push_str(&unescape_xstring(&text)),
136
+ Event::End("t") => in_text = false,
137
+ Event::End("rPh") => phonetic = phonetic.saturating_sub(1),
138
+ Event::End("si") => strings.ends.push(strings.text.len()),
139
+ Event::Text(_) | Event::End(_) => {}
140
+ }
141
+ }
142
+ Ok(strings)
143
+ }
144
+
145
+ struct Book<'a> {
146
+ strings: &'a Strings,
147
+ shown: &'a [Shown],
148
+ date1904: bool,
149
+ }
150
+
151
+ /// A cell's position, kind and format, while its children are read.
152
+ struct Head {
153
+ row: u32,
154
+ column: u32,
155
+ kind: Kind,
156
+ style: usize,
157
+ }
158
+
159
+ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>) -> Result<Grid> {
160
+ let mut grid = Grid::default();
161
+ let mut xml = XmlReader::new(text, budget.max_depth());
162
+ let (mut row, mut next_row, mut next_column) = (0_u32, 0_u32, 0_u32);
163
+ let (mut value, mut formula, mut inline) = (String::new(), String::new(), String::new());
164
+ let (mut head, mut into) = (None, Into::Nowhere);
165
+ while let Some(event) = xml.next()? {
166
+ budget.tick()?;
167
+ match event {
168
+ Event::Start(tag) => match tag.name() {
169
+ "row" => {
170
+ row = tag
171
+ .attr("r")
172
+ .and_then(|r| r.parse::<u32>().ok())
173
+ .map_or(next_row, |r| r.saturating_sub(1));
174
+ next_column = 0;
175
+ }
176
+ "c" => {
177
+ let (at_row, column) = tag
178
+ .attr("r")
179
+ .and_then(|reference| parse_cell_reference(&reference).ok())
180
+ .map_or((row, next_column), |(row, column)| (row, u32::from(column)));
181
+ let kind = match tag.attr("t").as_deref() {
182
+ Some("s") => Kind::Shared,
183
+ Some("inlineStr") => Kind::Inline,
184
+ Some("str") => Kind::Text,
185
+ Some("b") => Kind::Bool,
186
+ Some("e") => Kind::Error,
187
+ Some("d") => Kind::Iso,
188
+ _ => Kind::Number,
189
+ };
190
+ let style = tag.attr("s").and_then(|s| s.parse().ok()).unwrap_or(0);
191
+ head = Some(Head { row: at_row, column, kind, style });
192
+ value.clear();
193
+ formula.clear();
194
+ inline.clear();
195
+ }
196
+ "v" => into = Into::Value,
197
+ "f" => into = Into::Formula,
198
+ "t" if head.as_ref().is_some_and(|head| head.kind == Kind::Inline) => {
199
+ into = Into::Inline;
200
+ }
201
+ "rPh" => xml.skip()?,
202
+ _ => {}
203
+ },
204
+ Event::Text(text) => match into {
205
+ Into::Value => value.push_str(&text),
206
+ Into::Formula => formula.push_str(&text),
207
+ Into::Inline => inline.push_str(&unescape_xstring(&text)),
208
+ Into::Nowhere => {}
209
+ },
210
+ Event::End("v" | "f" | "t") => into = Into::Nowhere,
211
+ Event::End("c") => {
212
+ if let Some(head) = head.take() {
213
+ write_cell(&mut grid, &head, book, [&value, &formula, &inline], scratch);
214
+ next_column = head.column + 1;
215
+ }
216
+ }
217
+ Event::End("row") => next_row = row + 1,
218
+ Event::End(_) => {}
219
+ }
220
+ }
221
+ Ok(grid.finish())
222
+ }
223
+
224
+ fn write_cell(
225
+ grid: &mut Grid,
226
+ head: &Head,
227
+ book: &Book<'_>,
228
+ [value, formula, inline]: [&str; 3],
229
+ scratch: &mut String,
230
+ ) {
231
+ let out = grid.buffer();
232
+ let kind = match head.kind {
233
+ Kind::Shared => {
234
+ if let Some(text) = value.trim().parse().ok().and_then(|index| book.strings.get(index))
235
+ {
236
+ out.push_str(text);
237
+ }
238
+ Value::Text
239
+ }
240
+ Kind::Inline => {
241
+ out.push_str(inline);
242
+ Value::Text
243
+ }
244
+ Kind::Text | Kind::Iso => {
245
+ out.push_str(&unescape_xstring(value));
246
+ Value::Text
247
+ }
248
+ Kind::Error => {
249
+ out.push_str(&unescape_xstring(value));
250
+ Value::Error
251
+ }
252
+ Kind::Bool => {
253
+ let on = value.trim() == "1";
254
+ out.push_str(if on { "TRUE" } else { "FALSE" });
255
+ Value::Bool(on)
256
+ }
257
+ Kind::Number => match value.trim().parse::<f64>() {
258
+ Ok(number) => {
259
+ let shown = book.shown.get(head.style).copied().unwrap_or(Shown::General);
260
+ push_number(out, number, shown, book.date1904, scratch)
261
+ }
262
+ Err(_) if value.trim().is_empty() && !formula.trim().is_empty() => {
263
+ out.push('=');
264
+ out.push_str(formula.trim());
265
+ Value::Formula
266
+ }
267
+ Err(_) => {
268
+ out.push_str(value);
269
+ Value::Text
270
+ }
271
+ },
272
+ };
273
+ grid.end_cell(head.row, head.column, kind);
274
+ }
275
+
276
+ /// The sheet's comments.
277
+ fn comments(text: &str, budget: &Budget<'_>) -> Result<Vec<Note>> {
278
+ let mut xml = XmlReader::new(text, budget.max_depth());
279
+ let (mut authors, mut author, mut body) =
280
+ (Vec::<Arc<str>>::new(), String::new(), String::new());
281
+ let (mut in_author, mut in_text, mut position, mut author_id) = (false, false, None, 0);
282
+ let mut notes = Vec::new();
283
+ while let Some(event) = xml.next()? {
284
+ budget.tick()?;
285
+ match event {
286
+ Event::Start(tag) => match tag.name() {
287
+ "author" => in_author = true,
288
+ "comment" => {
289
+ position = parse_cell_reference(&tag.attr("ref").unwrap_or_default()).ok();
290
+ author_id =
291
+ tag.attr("authorId").and_then(|id| id.parse().ok()).unwrap_or(usize::MAX);
292
+ body.clear();
293
+ }
294
+ "t" => in_text = true,
295
+ "rPh" => xml.skip()?,
296
+ _ => {}
297
+ },
298
+ Event::Text(text) if in_author => author.push_str(&text),
299
+ Event::Text(text) if in_text => body.push_str(&unescape_xstring(&text)),
300
+ Event::End("author") => {
301
+ in_author = false;
302
+ authors.push(Arc::from(author.as_str()));
303
+ author.clear();
304
+ }
305
+ Event::End("t") => in_text = false,
306
+ // A comment on no cell has nowhere to be shown.
307
+ Event::End("comment") => {
308
+ if let Some((row, column)) = position.take() {
309
+ notes.push(Note {
310
+ row,
311
+ column: u32::from(column),
312
+ author: authors.get(author_id).filter(|name| !name.is_empty()).cloned(),
313
+ text: body.trim().to_owned(),
314
+ });
315
+ }
316
+ }
317
+ Event::Text(_) | Event::End(_) => {}
318
+ }
319
+ }
320
+ Ok(notes)
321
+ }
322
+
323
+ /// Which sheet shows each image: `(media part, sheet name)`, through the
324
+ /// sheets' drawings.
325
+ pub fn image_sheets(package: &Package<'_>, budget: &Budget<'_>) -> Result<Vec<(String, String)>> {
326
+ let main = package.main();
327
+ let relationships = package.relationships(main)?;
328
+ let (listed, _) = workbook(&package.main_text()?, &relationships, budget)?;
329
+ let mut shown = Vec::new();
330
+ for sheet in listed {
331
+ for drawing in package.relationships(&sheet.part)?.iter().filter(|r| r.kind == "drawing") {
332
+ for image in
333
+ package.relationships(&drawing.target)?.into_iter().filter(|r| r.kind == "image")
334
+ {
335
+ shown.push((image.target, sheet.name.clone()));
336
+ }
337
+ }
338
+ }
339
+ Ok(shown)
340
+ }
@@ -0,0 +1,145 @@
1
+ //! Documents in: the other direction. A PDF's text, an XLSX's sheets and a
2
+ //! DOCX's body, as [`Content`] to search or serialize, or as Markdown for
3
+ //! search indexes and language models.
4
+ //!
5
+ //! ```
6
+ //! use imprimatur::read::{Limits, markdown};
7
+ //! use imprimatur::xlsx::Workbook;
8
+ //!
9
+ //! let mut book = Workbook::new();
10
+ //! book.add_sheet("Sales")?.append_row(["Region", "Q1"])?.append_row(["North", "12.5"])?;
11
+ //! let text = markdown(&book.to_bytes()?, &Limits::default())?;
12
+ //! assert_eq!(text, "## Sales\n\n| Region | Q1 |\n| --- | --- |\n| North | 12.5 |\n");
13
+ //! # Ok::<(), imprimatur::Error>(())
14
+ //! ```
15
+
16
+ mod budget;
17
+ mod content;
18
+ mod docx;
19
+ mod image;
20
+ pub(crate) mod inflate;
21
+ #[cfg(feature = "json")]
22
+ pub mod json;
23
+ mod markdown;
24
+ mod package;
25
+ mod pdf;
26
+ mod span;
27
+ #[cfg(test)]
28
+ mod tests;
29
+ mod xlsx;
30
+
31
+ pub(crate) use budget::Budget;
32
+ pub use budget::Limits;
33
+ pub use content::{
34
+ Block, Bounds, Cell, Content, Grid, Mark, Note, Page, Placed, Sheet, Text, Value,
35
+ };
36
+ pub use image::{Image, ImageFormat, ImageLocation};
37
+
38
+ use crate::zip::reader::ZipReader;
39
+ use crate::{Error, Result};
40
+ use package::Package;
41
+
42
+ /// The formats [`content`] reads.
43
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44
+ #[non_exhaustive]
45
+ pub enum Format {
46
+ Pdf,
47
+ Xlsx,
48
+ Docx,
49
+ }
50
+
51
+ impl Format {
52
+ /// `pdf`, `xlsx` or `docx`.
53
+ #[must_use]
54
+ pub const fn as_str(self) -> &'static str {
55
+ match self {
56
+ Self::Pdf => "pdf",
57
+ Self::Xlsx => "xlsx",
58
+ Self::Docx => "docx",
59
+ }
60
+ }
61
+ }
62
+
63
+ /// Reads a document, its format told by its content: `%PDF-` in the first
64
+ /// kilobyte, or a zip whose main part is a workbook or a Word document
65
+ /// (macro-enabled ones and templates included).
66
+ ///
67
+ /// # Errors
68
+ /// [`Error::UnsupportedDocument`] for other formats (legacy `.xls` and
69
+ /// `.doc`, binary `.xlsb`, presentations), [`Error::NoTextLayer`] for a PDF
70
+ /// of scanned pages, [`Error::PasswordRequired`] for a PDF that needs one,
71
+ /// [`Error::LimitExceeded`] and [`Error::Cancelled`] when the [`Limits`]
72
+ /// stop the read, and [`Error::Malformed`] for a corrupt document.
73
+ pub fn content(document: &[u8], limits: &Limits) -> Result<Content> {
74
+ let budget = Budget::new(limits, document.len())?;
75
+ if pdf::is_pdf(document) {
76
+ return pdf::content(document, &budget).map(Content::Pdf);
77
+ }
78
+ let zip = zip(document)?;
79
+ let package = Package::open(&zip, &budget)?;
80
+ match package.format()? {
81
+ Format::Xlsx => xlsx::content(&package, &budget).map(Content::Xlsx),
82
+ Format::Docx => docx::content(&package, &budget).map(Content::Docx),
83
+ Format::Pdf => Err(Error::UnsupportedDocument("a PDF inside a zip".to_owned())),
84
+ }
85
+ }
86
+
87
+ /// Reads a document as Markdown: [`content`], then
88
+ /// [`Content::to_markdown`].
89
+ ///
90
+ /// # Errors
91
+ /// As [`content`].
92
+ pub fn markdown(document: &[u8], limits: &Limits) -> Result<String> {
93
+ content(document, limits).map(|content| content.to_markdown())
94
+ }
95
+
96
+ /// Calls `each` with every image in a document, as a file.
97
+ ///
98
+ /// A PDF's images come once each, from the first page that draws them; a
99
+ /// Word or Excel package gives its media. A JPEG stays a JPEG; pixels a PDF
100
+ /// stores raw become PNG. Stops at the first error `each` returns.
101
+ ///
102
+ /// # Errors
103
+ /// As [`content`] (but a PDF without text is no error here), and what
104
+ /// `each` returns.
105
+ pub fn images(
106
+ document: &[u8],
107
+ limits: &Limits,
108
+ mut each: impl FnMut(Image) -> Result<()>,
109
+ ) -> Result<()> {
110
+ let budget = Budget::new(limits, document.len())?;
111
+ if pdf::is_pdf(document) {
112
+ return pdf::images(document, &budget, &mut each);
113
+ }
114
+ let zip = zip(document)?;
115
+ let package = Package::open(&zip, &budget)?;
116
+ let format = package.format()?;
117
+ let sheets = match format {
118
+ Format::Xlsx => xlsx::image_sheets(&package, &budget)?,
119
+ _ => Vec::new(),
120
+ };
121
+ let folder = package.main().rsplit_once('/').map_or("", |(folder, _)| folder);
122
+ let media = format!("{folder}/media/");
123
+ let names: Vec<&str> = package.parts().filter(|name| name.starts_with(&media)).collect();
124
+ for name in names {
125
+ budget.check()?;
126
+ let Some(data) = package.part(name)? else { continue };
127
+ let format = ImageFormat::sniff(&data, name);
128
+ let (width, height) = format.dimensions(&data).unzip();
129
+ let sheet = sheets
130
+ .iter()
131
+ .find(|(part, _)| part.eq_ignore_ascii_case(name))
132
+ .map(|(_, sheet)| sheet.clone());
133
+ let location = ImageLocation::Part { name: name.to_owned(), sheet };
134
+ each(Image { data, format, width, height, location })?;
135
+ }
136
+ Ok(())
137
+ }
138
+
139
+ /// A document that is not a PDF, as the zip an Office document is.
140
+ fn zip(document: &[u8]) -> Result<ZipReader<'_>> {
141
+ if !document.starts_with(b"PK") {
142
+ return Err(Error::UnsupportedDocument("not a PDF, XLSX or DOCX file".to_owned()));
143
+ }
144
+ ZipReader::new(document)
145
+ }
@@ -0,0 +1,82 @@
1
+ //! What every format shares: colors, alignment and text direction.
2
+
3
+ #[cfg(test)]
4
+ mod tests;
5
+
6
+ use crate::{Error, Result};
7
+
8
+ /// An opaque RGB color.
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10
+ pub struct Color(u32);
11
+
12
+ impl Color {
13
+ pub const BLACK: Self = Self(0x00_0000);
14
+ pub const WHITE: Self = Self(0xFF_FFFF);
15
+
16
+ /// A color from `0xRRGGBB`; bits above 24 are ignored.
17
+ #[must_use]
18
+ pub const fn rgb(rgb: u32) -> Self {
19
+ Self(rgb & 0xFF_FFFF)
20
+ }
21
+
22
+ /// Parses `#RRGGBB` or `RRGGBB`.
23
+ ///
24
+ /// # Errors
25
+ /// [`Error::InvalidColor`] for anything else.
26
+ pub fn from_hex(hex: &str) -> Result<Self> {
27
+ let digits = hex.strip_prefix('#').unwrap_or(hex);
28
+ (digits.len() == 6 && digits.bytes().all(|byte| byte.is_ascii_hexdigit()))
29
+ .ok_or_else(|| Error::InvalidColor(hex.to_owned()))?;
30
+ u32::from_str_radix(digits, 16).map(Self).map_err(|_| Error::InvalidColor(hex.to_owned()))
31
+ }
32
+
33
+ /// Red, green and blue from 0 to 1, as PDF color operators take them.
34
+ pub(crate) fn components(self) -> [f64; 3] {
35
+ let [_, r, g, b] = self.0.to_be_bytes();
36
+ [r, g, b].map(|channel| f64::from(channel) / 255.0)
37
+ }
38
+
39
+ /// `FFRRGGBB`, the opaque ARGB `SpreadsheetML` writes.
40
+ pub(crate) fn argb(self) -> String {
41
+ format!("FF{:06X}", self.0)
42
+ }
43
+ }
44
+
45
+ /// Horizontal alignment within the cell.
46
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47
+ pub enum Align {
48
+ Left,
49
+ Center,
50
+ Right,
51
+ }
52
+
53
+ impl Align {
54
+ pub(crate) const fn as_str(self) -> &'static str {
55
+ match self {
56
+ Self::Left => "left",
57
+ Self::Center => "center",
58
+ Self::Right => "right",
59
+ }
60
+ }
61
+ }
62
+
63
+ /// The direction text reads in.
64
+ ///
65
+ /// For the right-to-left scripts: Hebrew (and Yiddish), Arabic (and Persian,
66
+ /// Urdu, Pashto, Kurdish), Syriac, Thaana, N'Ko, Adlam and the rest. Without
67
+ /// one, Excel infers it from the first strong character.
68
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69
+ pub enum TextDirection {
70
+ LeftToRight,
71
+ RightToLeft,
72
+ }
73
+
74
+ impl TextDirection {
75
+ /// `readingOrder`: 1 left to right, 2 right to left (0 is context).
76
+ pub(crate) const fn reading_order(self) -> &'static str {
77
+ match self {
78
+ Self::LeftToRight => "1",
79
+ Self::RightToLeft => "2",
80
+ }
81
+ }
82
+ }