imprimatur 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/Cargo.lock +124 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +123 -23
  6. data/ext/imprimatur_native/cli/Cargo.toml +2 -2
  7. data/ext/imprimatur_native/cli/src/cli/grep.rs +1 -1
  8. data/ext/imprimatur_native/cli/src/cli/markdown.rs +2 -2
  9. data/ext/imprimatur_native/cli/src/cli.rs +5 -5
  10. data/ext/imprimatur_native/cli/src/main.rs +1 -1
  11. data/ext/imprimatur_native/core/Cargo.toml +12 -2
  12. data/ext/imprimatur_native/core/README.md +15 -1
  13. data/ext/imprimatur_native/core/src/archive.rs +279 -0
  14. data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
  15. data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
  16. data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
  17. data/ext/imprimatur_native/core/src/doc.rs +86 -2
  18. data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
  19. data/ext/imprimatur_native/core/src/docx.rs +91 -28
  20. data/ext/imprimatur_native/core/src/error.rs +13 -1
  21. data/ext/imprimatur_native/core/src/font.rs +229 -0
  22. data/ext/imprimatur_native/core/src/lib.rs +5 -0
  23. data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
  24. data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
  25. data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
  26. data/ext/imprimatur_native/core/src/pdf.rs +31 -3
  27. data/ext/imprimatur_native/core/src/read/content.rs +91 -3
  28. data/ext/imprimatur_native/core/src/read/json.rs +96 -7
  29. data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
  30. data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +69 -45
  31. data/ext/imprimatur_native/core/src/read/ods.rs +356 -0
  32. data/ext/imprimatur_native/core/src/read/ole.rs +244 -0
  33. data/ext/imprimatur_native/core/src/read/package.rs +0 -5
  34. data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
  35. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
  36. data/ext/imprimatur_native/core/src/read/xls/strings.rs +156 -0
  37. data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
  38. data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
  39. data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -20
  40. data/ext/imprimatur_native/core/src/read.rs +68 -21
  41. data/ext/imprimatur_native/core/src/script.rs +116 -0
  42. data/ext/imprimatur_native/core/src/style.rs +21 -0
  43. data/ext/imprimatur_native/core/src/test_support.rs +90 -2
  44. data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
  45. data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
  46. data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
  47. data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
  48. data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
  49. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
  50. data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
  51. data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
  52. data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
  53. data/ext/imprimatur_native/ffi/src/archive.rs +119 -0
  54. data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
  55. data/ext/imprimatur_native/ffi/src/document.rs +48 -1
  56. data/ext/imprimatur_native/ffi/src/lib.rs +52 -1
  57. data/ext/imprimatur_native/ffi/src/read.rs +9 -14
  58. data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
  59. data/lib/imprimatur/document.rb +30 -6
  60. data/lib/imprimatur/error.rb +4 -3
  61. data/lib/imprimatur/reader.rb +3 -3
  62. data/lib/imprimatur/spreadsheet.rb +151 -0
  63. data/lib/imprimatur/version.rb +1 -1
  64. data/lib/imprimatur/xlsx.rb +25 -4
  65. data/lib/imprimatur/zip.rb +127 -0
  66. data/lib/imprimatur.rb +18 -5
  67. metadata +23 -4
@@ -3,20 +3,21 @@
3
3
  //! digits), formulas without a cached result as `=FORMULA`, then the sheet's
4
4
  //! comments.
5
5
 
6
- mod numbers;
6
+ mod formats;
7
7
  #[cfg(test)]
8
8
  mod tests;
9
9
 
10
10
  use std::sync::Arc;
11
11
 
12
+ use self::formats::{Formats, formats};
12
13
  use super::Budget;
13
- use super::content::{Grid, Note, Sheet, Value};
14
+ use super::content::{Columns, Grid, Note, Sheet, Styled, Validation, Value};
15
+ use super::numbers::{Shown, push_number};
14
16
  use super::package::Package;
15
17
  use crate::Result;
16
18
  use crate::xlsx::parse_cell_reference;
17
19
  use crate::xlsx::text::unescape as unescape_xstring;
18
- use crate::xml::reader::{Event, XmlReader};
19
- use numbers::{Shown, push_number, styles};
20
+ use crate::xml::reader::{Event, Tag, XmlReader};
20
21
 
21
22
  /// A cell's `t` attribute.
22
23
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -52,13 +53,24 @@ impl Strings {
52
53
  }
53
54
  }
54
55
 
55
- /// Where a cell's text events go.
56
+ /// Where a cell's text events go, or a validation's formulas'.
56
57
  #[derive(Clone, Copy, PartialEq, Eq)]
57
58
  enum Into {
58
59
  Nowhere,
59
60
  Value,
60
61
  Formula,
61
62
  Inline,
63
+ First,
64
+ Second,
65
+ }
66
+
67
+ /// What a sheet holds besides its cells' values.
68
+ #[derive(Default)]
69
+ struct Parts {
70
+ columns: Vec<Columns>,
71
+ validations: Vec<Validation>,
72
+ /// Cells with a format of their own: row, column, format.
73
+ styled: Vec<(u32, u32, usize)>,
62
74
  }
63
75
 
64
76
  pub fn content(package: &Package<'_>, budget: &Budget<'_>) -> Result<Vec<Sheet>> {
@@ -68,21 +80,31 @@ pub fn content(package: &Package<'_>, budget: &Budget<'_>) -> Result<Vec<Sheet>>
68
80
  let (listed, date1904) = workbook(&text, &relationships, budget)?;
69
81
  let strings = package
70
82
  .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 };
83
+ let formats = package.parse_related(&relationships, "styles", |text| formats(text, budget))?;
84
+ let book = Book { strings: &strings, shown: &formats.shown, date1904 };
73
85
  let mut sheets = Vec::with_capacity(listed.len());
74
86
  let mut scratch = String::new();
75
87
  for sheet in listed {
76
88
  budget.check()?;
77
89
  let Some(text) = package.text(&sheet.part)? else { continue };
78
- let cells = cells(&text, &book, &mut scratch, budget)?;
90
+ let (cells, parts) = cells(&text, &book, &mut scratch, budget)?;
79
91
  let comments = match package.related(&sheet.part, "comments")? {
80
92
  Some(part) => package
81
93
  .text(&part)?
82
94
  .map_or_else(|| Ok(Vec::new()), |text| comments(&text, budget))?,
83
95
  None => Vec::new(),
84
96
  };
85
- sheets.push(Sheet { name: sheet.name, hidden: sheet.hidden, cells, comments });
97
+ let styles = styled(&parts.styled, &formats);
98
+ let (columns, validations) = (parts.columns, parts.validations);
99
+ sheets.push(Sheet {
100
+ name: sheet.name,
101
+ hidden: sheet.hidden,
102
+ cells,
103
+ comments,
104
+ columns,
105
+ validations,
106
+ styles,
107
+ });
86
108
  }
87
109
  Ok(sheets)
88
110
  }
@@ -156,8 +178,13 @@ struct Head {
156
178
  style: usize,
157
179
  }
158
180
 
159
- fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>) -> Result<Grid> {
160
- let mut grid = Grid::default();
181
+ fn cells(
182
+ text: &str,
183
+ book: &Book<'_>,
184
+ scratch: &mut String,
185
+ budget: &Budget<'_>,
186
+ ) -> Result<(Grid, Parts)> {
187
+ let (mut grid, mut parts, mut rule) = (Grid::default(), Parts::default(), None);
161
188
  let mut xml = XmlReader::new(text, budget.max_depth());
162
189
  let (mut row, mut next_row, mut next_column) = (0_u32, 0_u32, 0_u32);
163
190
  let (mut value, mut formula, mut inline) = (String::new(), String::new(), String::new());
@@ -167,10 +194,7 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
167
194
  match event {
168
195
  Event::Start(tag) => match tag.name() {
169
196
  "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));
197
+ row = tag.parse::<u32>("r").map_or(next_row, |r| r.saturating_sub(1));
174
198
  next_column = 0;
175
199
  }
176
200
  "c" => {
@@ -187,7 +211,7 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
187
211
  Some("d") => Kind::Iso,
188
212
  _ => Kind::Number,
189
213
  };
190
- let style = tag.attr("s").and_then(|s| s.parse().ok()).unwrap_or(0);
214
+ let style = tag.parse("s").unwrap_or(0);
191
215
  head = Some(Head { row: at_row, column, kind, style });
192
216
  value.clear();
193
217
  formula.clear();
@@ -199,17 +223,36 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
199
223
  into = Into::Inline;
200
224
  }
201
225
  "rPh" => xml.skip()?,
226
+ "col" => parts.columns.extend(columns(tag)),
227
+ "dataValidation" => rule = Some(validation(tag)),
228
+ "formula1" if rule.is_some() => (into, value) = (Into::First, String::new()),
229
+ "formula2" if rule.is_some() => (into, value) = (Into::Second, String::new()),
202
230
  _ => {}
203
231
  },
204
232
  Event::Text(text) => match into {
205
- Into::Value => value.push_str(&text),
233
+ Into::Value | Into::First | Into::Second => value.push_str(&text),
206
234
  Into::Formula => formula.push_str(&text),
207
235
  Into::Inline => inline.push_str(&unescape_xstring(&text)),
208
236
  Into::Nowhere => {}
209
237
  },
210
238
  Event::End("v" | "f" | "t") => into = Into::Nowhere,
239
+ Event::End(end @ ("formula1" | "formula2")) => {
240
+ if let Some(rule) = rule.as_mut() {
241
+ let formula = Some(std::mem::take(&mut value));
242
+ if end == "formula1" {
243
+ rule.formula1 = formula;
244
+ } else {
245
+ rule.formula2 = formula;
246
+ }
247
+ }
248
+ into = Into::Nowhere;
249
+ }
250
+ Event::End("dataValidation") => parts.validations.extend(rule.take()),
211
251
  Event::End("c") => {
212
252
  if let Some(head) = head.take() {
253
+ if head.style != 0 {
254
+ parts.styled.push((head.row, head.column, head.style));
255
+ }
213
256
  write_cell(&mut grid, &head, book, [&value, &formula, &inline], scratch);
214
257
  next_column = head.column + 1;
215
258
  }
@@ -218,7 +261,7 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
218
261
  Event::End(_) => {}
219
262
  }
220
263
  }
221
- Ok(grid.finish())
264
+ Ok((grid.finish(), parts))
222
265
  }
223
266
 
224
267
  fn write_cell(
@@ -273,6 +316,48 @@ fn write_cell(
273
316
  grid.end_cell(head.row, head.column, kind);
274
317
  }
275
318
 
319
+ /// A `<col>`: a run of columns, from 0.
320
+ fn columns(tag: Tag<'_>) -> Option<Columns> {
321
+ let number = |name| tag.parse::<u32>(name);
322
+ let (first, last) = (number("min")?.checked_sub(1)?, number("max")?.checked_sub(1)?);
323
+ let width = tag.parse("width");
324
+ let hidden = tag.on("hidden");
325
+ Some(Columns { first, last, width, hidden })
326
+ }
327
+
328
+ /// A `<dataValidation>`, its formulas still to come.
329
+ fn validation(tag: Tag<'_>) -> Validation {
330
+ let text = |name| tag.attr(name).map(std::borrow::Cow::into_owned);
331
+ Validation {
332
+ range: text("sqref").unwrap_or_default(),
333
+ kind: text("type").unwrap_or_else(|| "none".to_owned()),
334
+ operator: text("operator"),
335
+ allow_blank: tag.on("allowBlank"),
336
+ // Inverted in the file format: showDropDown="1" hides the arrow.
337
+ dropdown: !tag.on("showDropDown"),
338
+ alert: tag
339
+ .on("showErrorMessage")
340
+ .then(|| text("errorStyle").unwrap_or_else(|| "stop".to_owned())),
341
+ error_title: text("errorTitle"),
342
+ error: text("error"),
343
+ prompt_title: text("promptTitle"),
344
+ prompt: text("prompt"),
345
+ ..Validation::default()
346
+ }
347
+ }
348
+
349
+ /// The formatted cells with their look; formats the workbook lacks are
350
+ /// left out.
351
+ fn styled(cells: &[(u32, u32, usize)], formats: &Formats) -> Vec<Styled> {
352
+ cells
353
+ .iter()
354
+ .filter_map(|&(row, column, format)| {
355
+ let look = Arc::clone(formats.looks.get(format)?);
356
+ Some(Styled { row, column, look })
357
+ })
358
+ .collect()
359
+ }
360
+
276
361
  /// The sheet's comments.
277
362
  fn comments(text: &str, budget: &Budget<'_>) -> Result<Vec<Note>> {
278
363
  let mut xml = XmlReader::new(text, budget.max_depth());
@@ -287,8 +372,7 @@ fn comments(text: &str, budget: &Budget<'_>) -> Result<Vec<Note>> {
287
372
  "author" => in_author = true,
288
373
  "comment" => {
289
374
  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);
375
+ author_id = tag.parse("authorId").unwrap_or(usize::MAX);
292
376
  body.clear();
293
377
  }
294
378
  "t" => in_text = true,
@@ -21,19 +21,26 @@ pub(crate) mod inflate;
21
21
  #[cfg(feature = "json")]
22
22
  pub mod json;
23
23
  mod markdown;
24
+ mod numbers;
25
+ mod ods;
26
+ mod ole;
24
27
  mod package;
25
28
  mod pdf;
26
29
  mod span;
27
30
  #[cfg(test)]
28
31
  mod tests;
32
+ mod xls;
29
33
  mod xlsx;
30
34
 
31
35
  pub(crate) use budget::Budget;
32
36
  pub use budget::Limits;
33
37
  pub use content::{
34
- Block, Bounds, Cell, Content, Grid, Mark, Note, Page, Placed, Sheet, Text, Value,
38
+ Block, Bounds, Cell, Columns, Content, Grid, Look, Mark, Note, Page, Placed, Sheet, Styled,
39
+ Text, Validation, Value,
35
40
  };
36
41
  pub use image::{Image, ImageFormat, ImageLocation};
42
+ #[cfg(test)]
43
+ pub(crate) use image::{PngColor, png};
37
44
 
38
45
  use crate::zip::reader::ZipReader;
39
46
  use crate::{Error, Result};
@@ -46,41 +53,62 @@ pub enum Format {
46
53
  Pdf,
47
54
  Xlsx,
48
55
  Docx,
56
+ Ods,
57
+ Xls,
49
58
  }
50
59
 
51
60
  impl Format {
52
- /// `pdf`, `xlsx` or `docx`.
61
+ /// `pdf`, `xlsx`, `docx`, `ods` or `xls`.
53
62
  #[must_use]
54
63
  pub const fn as_str(self) -> &'static str {
55
64
  match self {
56
65
  Self::Pdf => "pdf",
57
66
  Self::Xlsx => "xlsx",
58
67
  Self::Docx => "docx",
68
+ Self::Ods => "ods",
69
+ Self::Xls => "xls",
59
70
  }
60
71
  }
61
72
  }
62
73
 
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).
74
+ /// Reads a document, its format told by its content.
75
+ ///
76
+ /// That is `%PDF-` in the first kilobyte; a compound file holding a legacy
77
+ /// Excel workbook (`.xls`); a
78
+ /// zip that is an ODF spreadsheet (`.ods`), or whose main part is
79
+ /// a workbook or a Word document (macro-enabled ones and templates
80
+ /// included).
66
81
  ///
67
82
  /// # 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.
83
+ /// [`Error::UnsupportedDocument`] for other formats (legacy `.doc`, binary
84
+ /// `.xlsb`, presentations, ODF text), [`Error::NoTextLayer`] for a
85
+ /// PDF of scanned pages, [`Error::PasswordRequired`] for a document that
86
+ /// needs one, [`Error::LimitExceeded`] and [`Error::Cancelled`] when the
87
+ /// [`Limits`] stop the read, and [`Error::Malformed`] for a corrupt document.
73
88
  pub fn content(document: &[u8], limits: &Limits) -> Result<Content> {
74
89
  let budget = Budget::new(limits, document.len())?;
75
90
  if pdf::is_pdf(document) {
76
91
  return pdf::content(document, &budget).map(Content::Pdf);
77
92
  }
93
+ if document.starts_with(&ole::SIGNATURE) {
94
+ let ole = ole::Ole::open(document, &budget)?;
95
+ return xls::content(&ole, &budget).map(Content::Xls);
96
+ }
78
97
  let zip = zip(document)?;
98
+ match ods::spreadsheet(&zip, &budget)? {
99
+ Some(true) => return ods::content(&zip, &budget).map(Content::Ods),
100
+ Some(false) => {
101
+ return Err(Error::UnsupportedDocument(
102
+ "an OpenDocument file that is not a spreadsheet".to_owned(),
103
+ ));
104
+ }
105
+ None => {}
106
+ }
79
107
  let package = Package::open(&zip, &budget)?;
80
108
  match package.format()? {
81
109
  Format::Xlsx => xlsx::content(&package, &budget).map(Content::Xlsx),
82
110
  Format::Docx => docx::content(&package, &budget).map(Content::Docx),
83
- Format::Pdf => Err(Error::UnsupportedDocument("a PDF inside a zip".to_owned())),
111
+ other => Err(Error::UnsupportedDocument(format!("a {} inside a zip", other.as_str()))),
84
112
  }
85
113
  }
86
114
 
@@ -96,12 +124,13 @@ pub fn markdown(document: &[u8], limits: &Limits) -> Result<String> {
96
124
  /// Calls `each` with every image in a document, as a file.
97
125
  ///
98
126
  /// 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.
127
+ /// Word, Excel or ODF package gives its media. A JPEG stays a
128
+ /// JPEG; pixels a PDF stores raw become PNG. Stops at the first error
129
+ /// `each` returns.
101
130
  ///
102
131
  /// # Errors
103
- /// As [`content`] (but a PDF without text is no error here), and what
104
- /// `each` returns.
132
+ /// As [`content`] (but a PDF without text is no error here, and a legacy
133
+ /// `.xls` is one), and what `each` returns.
105
134
  pub fn images(
106
135
  document: &[u8],
107
136
  limits: &Limits,
@@ -111,19 +140,35 @@ pub fn images(
111
140
  if pdf::is_pdf(document) {
112
141
  return pdf::images(document, &budget, &mut each);
113
142
  }
143
+ if document.starts_with(&ole::SIGNATURE) {
144
+ return Err(Error::UnsupportedDocument("the images of a legacy Office file".to_owned()));
145
+ }
114
146
  let zip = zip(document)?;
147
+ if ods::spreadsheet(&zip, &budget)?.is_some() {
148
+ return media(&zip, "Pictures/", &[], &budget, &mut each);
149
+ }
115
150
  let package = Package::open(&zip, &budget)?;
116
- let format = package.format()?;
117
- let sheets = match format {
151
+ let sheets = match package.format()? {
118
152
  Format::Xlsx => xlsx::image_sheets(&package, &budget)?,
119
153
  _ => Vec::new(),
120
154
  };
121
155
  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();
156
+ media(&zip, &format!("{folder}/media/"), &sheets, &budget, &mut each)
157
+ }
158
+
159
+ /// The package's parts under `folder`, as images; `sheets` says which
160
+ /// sheet shows each, by part.
161
+ fn media(
162
+ zip: &ZipReader<'_>,
163
+ folder: &str,
164
+ sheets: &[(String, String)],
165
+ budget: &Budget<'_>,
166
+ each: &mut impl FnMut(Image) -> Result<()>,
167
+ ) -> Result<()> {
168
+ let names: Vec<&str> = zip.names().filter(|name| name.starts_with(folder)).collect();
124
169
  for name in names {
125
170
  budget.check()?;
126
- let Some(data) = package.part(name)? else { continue };
171
+ let Some(data) = zip.read(name, budget)? else { continue };
127
172
  let format = ImageFormat::sniff(&data, name);
128
173
  let (width, height) = format.dimensions(&data).unzip();
129
174
  let sheet = sheets
@@ -139,7 +184,9 @@ pub fn images(
139
184
  /// A document that is not a PDF, as the zip an Office document is.
140
185
  fn zip(document: &[u8]) -> Result<ZipReader<'_>> {
141
186
  if !document.starts_with(b"PK") {
142
- return Err(Error::UnsupportedDocument("not a PDF, XLSX or DOCX file".to_owned()));
187
+ return Err(Error::UnsupportedDocument(
188
+ "not a PDF, XLSX, XLS, ODS or DOCX file".to_owned(),
189
+ ));
143
190
  }
144
191
  ZipReader::new(document)
145
192
  }
@@ -0,0 +1,116 @@
1
+ //! Scripts and the direction they are written in, for reading text back
2
+ //! in order and for writing it with the right direction.
3
+
4
+ #[cfg(test)]
5
+ mod tests;
6
+
7
+ use crate::style::TextDirection;
8
+
9
+ /// What a character is, for the direction of the text around it (its
10
+ /// UAX #9 bidirectional class, simplified).
11
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12
+ pub enum Class {
13
+ /// A letter of Hebrew or another right-to-left script but Arabic's, or
14
+ /// the right-to-left mark.
15
+ Right,
16
+ /// A letter of the Arabic script (Arabic, Persian, Urdu..., Syriac,
17
+ /// Thaana): right to left, and the digits after it are Arabic numbers.
18
+ Arabic,
19
+ /// Any other letter, or the left-to-right mark.
20
+ Left,
21
+ /// A number: its digits read left to right in any line, but in a
22
+ /// right-to-left run the spaces between two numbers read right to left.
23
+ Number,
24
+ /// Arabic-Indic digits (not the Persian ones), and digits after Arabic
25
+ /// letters: a number that only `,` `.` `:` `/` join, and no sign goes
26
+ /// with (UAX #9, W2).
27
+ ArabicNumber,
28
+ /// Spaces, punctuation and symbols: they take the direction around them.
29
+ Neutral,
30
+ }
31
+
32
+ impl Class {
33
+ pub const fn right_to_left(self) -> bool {
34
+ matches!(self, Self::Right | Self::Arabic)
35
+ }
36
+
37
+ pub const fn number(self) -> bool {
38
+ matches!(self, Self::Number | Self::ArabicNumber)
39
+ }
40
+
41
+ pub const fn strong(self) -> bool {
42
+ matches!(self, Self::Right | Self::Arabic | Self::Left)
43
+ }
44
+ }
45
+
46
+ /// The letters and digits of some text, by direction.
47
+ #[derive(Debug, Default, Clone, Copy)]
48
+ pub struct Counts {
49
+ pub right: usize,
50
+ pub left: usize,
51
+ pub numbers: usize,
52
+ }
53
+
54
+ impl Counts {
55
+ pub fn add(&mut self, text: &str) {
56
+ for ch in text.chars() {
57
+ match class(ch) {
58
+ Class::Right | Class::Arabic => self.right += 1,
59
+ Class::Left if ch.is_alphabetic() => self.left += 1,
60
+ Class::Number | Class::ArabicNumber => self.numbers += 1,
61
+ Class::Left | Class::Neutral => {}
62
+ }
63
+ }
64
+ }
65
+
66
+ /// Whether more of the letters read right to left than left to right.
67
+ pub const fn right_to_left(self) -> bool {
68
+ self.right > self.left
69
+ }
70
+
71
+ /// The direction most of the letters read in; `None` without a letter.
72
+ pub const fn direction(self) -> Option<TextDirection> {
73
+ if self.right > self.left {
74
+ Some(TextDirection::RightToLeft)
75
+ } else if self.left > 0 {
76
+ Some(TextDirection::LeftToRight)
77
+ } else {
78
+ None
79
+ }
80
+ }
81
+ }
82
+
83
+ /// The direction of a character.
84
+ ///
85
+ /// Right to left: Hebrew (and Yiddish, Ladino), Arabic (and Persian, Urdu,
86
+ /// Pashto, Sindhi, Kurdish, Uyghur), Syriac, Thaana (Dhivehi), N'Ko,
87
+ /// Samaritan, Mandaic, their presentation forms; Adlam (Fulani), Hanifi
88
+ /// Rohingya, Mende Kikakui, Yezidi, Garay and the historic scripts
89
+ /// (Phoenician, Aramaic, Sogdian...); and the right-to-left mark. Of
90
+ /// those, the Arabic script (with Syriac, Thaana, Hanifi Rohingya and the
91
+ /// Siyaq numbers) is set apart. Digits are numbers, the Persian ones
92
+ /// (Extended Arabic-Indic) too; Arabic-Indic ones are Arabic numbers. The
93
+ /// Arabic comma, percent sign and separators are neutral, like their Latin
94
+ /// counterparts.
95
+ pub fn class(ch: char) -> Class {
96
+ match ch {
97
+ '0'..='9' | '\u{06F0}'..='\u{06F9}' => Class::Number,
98
+ '\u{0660}'..='\u{0669}' => Class::ArabicNumber,
99
+ '\u{060C}' | '\u{066A}'..='\u{066C}' => Class::Neutral,
100
+ '\u{0600}'..='\u{07BF}'
101
+ | '\u{0860}'..='\u{08FF}'
102
+ | '\u{FB50}'..='\u{FDFF}'
103
+ | '\u{FE70}'..='\u{FEFC}'
104
+ | '\u{10D00}'..='\u{10D3F}'
105
+ | '\u{10EC0}'..='\u{10EFF}'
106
+ | '\u{1EC70}'..='\u{1EEFF}' => Class::Arabic,
107
+ '\u{0590}'..='\u{08FF}'
108
+ | '\u{200F}'
109
+ | '\u{FB1D}'..='\u{FB4F}'
110
+ | '\u{10800}'..='\u{10FFF}'
111
+ | '\u{1E800}'..='\u{1EFFF}' => Class::Right,
112
+ '\u{200E}' => Class::Left,
113
+ ch if ch.is_alphanumeric() => Class::Left,
114
+ _ => Class::Neutral,
115
+ }
116
+ }
@@ -3,6 +3,7 @@
3
3
  #[cfg(test)]
4
4
  mod tests;
5
5
 
6
+ use crate::script::{Class, class};
6
7
  use crate::{Error, Result};
7
8
 
8
9
  /// An opaque RGB color.
@@ -72,6 +73,26 @@ pub enum TextDirection {
72
73
  }
73
74
 
74
75
  impl TextDirection {
76
+ /// The direction text takes on its own, the way Word and browsers settle
77
+ /// a paragraph left to automatic: that of its first letter (UAX #9, P2).
78
+ /// `None` when it has no letter: digits, punctuation, nothing.
79
+ ///
80
+ /// ```
81
+ /// use imprimatur::style::TextDirection;
82
+ ///
83
+ /// assert_eq!(TextDirection::of("2024 شهادة"), Some(TextDirection::RightToLeft));
84
+ /// assert_eq!(TextDirection::of("ISO 9001 אישור"), Some(TextDirection::LeftToRight));
85
+ /// assert_eq!(TextDirection::of("1,250.50"), None);
86
+ /// ```
87
+ #[must_use]
88
+ pub fn of(text: &str) -> Option<Self> {
89
+ text.chars().find_map(|ch| match class(ch) {
90
+ Class::Right | Class::Arabic => Some(Self::RightToLeft),
91
+ Class::Left => Some(Self::LeftToRight),
92
+ Class::Number | Class::ArabicNumber | Class::Neutral => None,
93
+ })
94
+ }
95
+
75
96
  /// `readingOrder`: 1 left to right, 2 right to left (0 is context).
76
97
  pub(crate) const fn reading_order(self) -> &'static str {
77
98
  match self {
@@ -16,11 +16,12 @@ pub fn read_zip(bytes: Vec<u8>) -> Vec<(String, Vec<u8>)> {
16
16
  .collect()
17
17
  }
18
18
 
19
- /// Every entry of a zip archive as text, by name, read as [`read_zip`] does.
19
+ /// Every entry of a zip archive as text, by name, read as [`read_zip`] does;
20
+ /// media come out garbled, for their names.
20
21
  pub fn text_parts(bytes: Vec<u8>) -> Vec<(String, String)> {
21
22
  read_zip(bytes)
22
23
  .into_iter()
23
- .map(|(name, data)| (name, String::from_utf8(data).unwrap()))
24
+ .map(|(name, data)| (name, String::from_utf8_lossy(&data).into_owned()))
24
25
  .collect()
25
26
  }
26
27
 
@@ -161,3 +162,90 @@ pub fn tiff_of(data: &[u8]) -> (std::collections::BTreeMap<u16, u32>, &[u8]) {
161
162
  let (offset, length) = (tags[&273] as usize, tags[&279] as usize);
162
163
  (tags, &data[offset..offset + length])
163
164
  }
165
+
166
+ /// A compound file (MS-CFB, version 3) holding one stream named `name` in
167
+ /// its root storage; a stream under 4096 bytes goes to the mini stream, as
168
+ /// writers put it.
169
+ pub fn ole_of(name: &str, stream: &[u8]) -> Vec<u8> {
170
+ const SECTOR: usize = 512;
171
+ const END: u32 = 0xFFFF_FFFE;
172
+ let small = stream.len() < 4096;
173
+ let pad = |data: &[u8], size: usize| {
174
+ let mut data = data.to_vec();
175
+ data.resize(data.len().div_ceil(size).max(1) * size, 0);
176
+ data
177
+ };
178
+ // Sectors: FAT, directory, mini FAT (when small), then the data: the
179
+ // stream itself, or the mini stream holding it.
180
+ let data = if small { pad(stream, 64) } else { stream.to_vec() };
181
+ let data_sectors = data.len().div_ceil(SECTOR).max(1);
182
+ let first_data = if small { 3 } else { 2 };
183
+ let mut fat = vec![0xFFFF_FFFD_u32, END];
184
+ if small {
185
+ fat.push(END);
186
+ }
187
+ fat.extend((0..data_sectors).map(|index| {
188
+ if index + 1 == data_sectors { END } else { u32::try_from(first_data + index + 1).unwrap() }
189
+ }));
190
+ fat.resize(SECTOR / 4, 0xFFFF_FFFF);
191
+ let mut header = vec![0_u8; SECTOR];
192
+ header[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
193
+ let put = |bytes: &mut Vec<u8>, at: usize, value: u32| {
194
+ bytes[at..at + 4].copy_from_slice(&value.to_le_bytes());
195
+ };
196
+ header[0x18..0x1A].copy_from_slice(&0x3E_u16.to_le_bytes());
197
+ header[0x1A..0x1C].copy_from_slice(&3_u16.to_le_bytes());
198
+ header[0x1C..0x1E].copy_from_slice(&0xFFFE_u16.to_le_bytes());
199
+ header[0x1E..0x20].copy_from_slice(&9_u16.to_le_bytes());
200
+ header[0x20..0x22].copy_from_slice(&6_u16.to_le_bytes());
201
+ put(&mut header, 0x2C, 1);
202
+ put(&mut header, 0x30, 1);
203
+ put(&mut header, 0x38, 4096);
204
+ put(&mut header, 0x3C, if small { 2 } else { END });
205
+ put(&mut header, 0x40, u32::from(small));
206
+ put(&mut header, 0x44, END);
207
+ put(&mut header, 0x4C, 0);
208
+ for index in 1..109 {
209
+ put(&mut header, 0x4C + index * 4, 0xFFFF_FFFF);
210
+ }
211
+ let entry = |name: &str, kind: u8, child: u32, start: u32, size: usize| {
212
+ let mut bytes = vec![0_u8; 128];
213
+ let units: Vec<u16> = name.encode_utf16().collect();
214
+ for (index, unit) in units.iter().enumerate() {
215
+ bytes[index * 2..index * 2 + 2].copy_from_slice(&unit.to_le_bytes());
216
+ }
217
+ bytes[0x40..0x42]
218
+ .copy_from_slice(&u16::try_from(units.len() * 2 + 2).unwrap().to_le_bytes());
219
+ bytes[0x42] = kind;
220
+ for at in [0x44, 0x48, 0x4C] {
221
+ put(&mut bytes, at, 0xFFFF_FFFF);
222
+ }
223
+ put(&mut bytes, 0x4C, child);
224
+ put(&mut bytes, 0x74, start);
225
+ put(&mut bytes, 0x78, u32::try_from(size).unwrap());
226
+ bytes
227
+ };
228
+ let first = u32::try_from(first_data).unwrap();
229
+ let mut directory = if small {
230
+ let mut directory = entry("Root Entry", 5, 1, first, data.len());
231
+ directory.extend(entry(name, 2, 0xFFFF_FFFF, 0, stream.len()));
232
+ directory
233
+ } else {
234
+ let mut directory = entry("Root Entry", 5, 1, END, 0);
235
+ directory.extend(entry(name, 2, 0xFFFF_FFFF, first, stream.len()));
236
+ directory
237
+ };
238
+ directory.resize(SECTOR, 0);
239
+ let mut file = header;
240
+ file.extend(fat.iter().flat_map(|word| word.to_le_bytes()));
241
+ file.extend(directory);
242
+ if small {
243
+ let mut mini_fat: Vec<u32> =
244
+ (1..data.len() / 64).map(|next| u32::try_from(next).unwrap()).collect();
245
+ mini_fat.push(END);
246
+ mini_fat.resize(SECTOR / 4, 0xFFFF_FFFF);
247
+ file.extend(mini_fat.iter().flat_map(|word| word.to_le_bytes()));
248
+ }
249
+ file.extend(pad(&data, SECTOR));
250
+ file
251
+ }