imprimatur 0.2.0 → 0.4.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/Cargo.lock +153 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +139 -21
  6. data/ext/imprimatur_native/cli/Cargo.toml +3 -1
  7. data/ext/imprimatur_native/core/Cargo.toml +20 -4
  8. data/ext/imprimatur_native/core/README.md +36 -4
  9. data/ext/imprimatur_native/core/src/archive.rs +3 -16
  10. data/ext/imprimatur_native/core/src/barcode/code128.rs +162 -0
  11. data/ext/imprimatur_native/core/src/barcode/draw.rs +124 -0
  12. data/ext/imprimatur_native/core/src/barcode/ean.rs +91 -0
  13. data/ext/imprimatur_native/core/src/barcode/gs1.rs +188 -0
  14. data/ext/imprimatur_native/core/src/barcode/matrix.rs +130 -0
  15. data/ext/imprimatur_native/core/src/barcode.rs +314 -0
  16. data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
  17. data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
  18. data/ext/imprimatur_native/core/src/doc/symbol.rs +136 -0
  19. data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
  20. data/ext/imprimatur_native/core/src/doc.rs +96 -2
  21. data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +103 -28
  23. data/ext/imprimatur_native/core/src/error.rs +18 -0
  24. data/ext/imprimatur_native/core/src/font.rs +231 -0
  25. data/ext/imprimatur_native/core/src/lib.rs +6 -1
  26. data/ext/imprimatur_native/core/src/pdf/layout.rs +72 -15
  27. data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
  28. data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
  29. data/ext/imprimatur_native/core/src/pdf/truetype/descriptor.rs +129 -0
  30. data/ext/imprimatur_native/core/src/pdf/truetype/program.rs +70 -0
  31. data/ext/imprimatur_native/core/src/pdf/truetype.rs +149 -0
  32. data/ext/imprimatur_native/core/src/pdf.rs +40 -4
  33. data/ext/imprimatur_native/core/src/read/content.rs +75 -2
  34. data/ext/imprimatur_native/core/src/read/image.rs +17 -0
  35. data/ext/imprimatur_native/core/src/read/json.rs +94 -7
  36. data/ext/imprimatur_native/core/src/read/numbers.rs +65 -42
  37. data/ext/imprimatur_native/core/src/read/ods.rs +1 -2
  38. data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
  39. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
  40. data/ext/imprimatur_native/core/src/read/xls.rs +1 -1
  41. data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
  42. data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -19
  43. data/ext/imprimatur_native/core/src/read.rs +5 -1
  44. data/ext/imprimatur_native/core/src/script.rs +116 -0
  45. data/ext/imprimatur_native/core/src/style.rs +32 -0
  46. data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
  47. data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
  48. data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
  49. data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
  50. data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
  51. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
  52. data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
  53. data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
  54. data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
  55. data/ext/imprimatur_native/ffi/src/archive.rs +1 -3
  56. data/ext/imprimatur_native/ffi/src/barcode.rs +147 -0
  57. data/ext/imprimatur_native/ffi/src/document.rs +76 -1
  58. data/ext/imprimatur_native/ffi/src/lib.rs +40 -1
  59. data/ext/imprimatur_native/ffi/src/read.rs +8 -11
  60. data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
  61. data/lib/imprimatur/barcode.rb +78 -0
  62. data/lib/imprimatur/document.rb +46 -6
  63. data/lib/imprimatur/reader.rb +2 -2
  64. data/lib/imprimatur/spreadsheet.rb +151 -0
  65. data/lib/imprimatur/version.rb +1 -1
  66. data/lib/imprimatur/xlsx.rb +25 -4
  67. data/lib/imprimatur.rb +19 -3
  68. metadata +23 -2
  69. data/ext/imprimatur_native/core/src/test_support.rs +0 -250
@@ -0,0 +1,155 @@
1
+ //! A workbook's cell formats (`styles.xml`): for each, how it shows numbers
2
+ //! and how it looks.
3
+
4
+ use std::borrow::Cow;
5
+ use std::sync::Arc;
6
+
7
+ use crate::Result;
8
+ use crate::read::Budget;
9
+ use crate::read::content::Look;
10
+ use crate::read::numbers::{Shown, built_in, classify};
11
+ use crate::xml::reader::{Event, XmlReader};
12
+
13
+ /// The cell formats (`cellXfs`), by index.
14
+ #[derive(Default)]
15
+ pub struct Formats {
16
+ pub shown: Vec<Shown>,
17
+ pub looks: Vec<Arc<Look>>,
18
+ }
19
+
20
+ /// Where in `styles.xml` the reader is.
21
+ #[derive(Clone, Copy, PartialEq, Eq)]
22
+ enum Section {
23
+ Other,
24
+ Fonts,
25
+ Fills,
26
+ Borders,
27
+ CellFormats,
28
+ }
29
+
30
+ /// A font, fill and border by their index, before the formats pick them.
31
+ #[derive(Default)]
32
+ struct Parts {
33
+ codes: Vec<(u32, String)>,
34
+ fonts: Vec<Look>,
35
+ fills: Vec<Option<String>>,
36
+ borders: Vec<Option<String>>,
37
+ }
38
+
39
+ pub fn formats(text: &str, budget: &Budget<'_>) -> Result<Formats> {
40
+ let mut xml = XmlReader::new(text, budget.max_depth());
41
+ let (mut parts, mut section, mut side) = (Parts::default(), Section::Other, "");
42
+ let mut formats = Formats { shown: Vec::new(), looks: Vec::new() };
43
+ while let Some(event) = xml.next()? {
44
+ match event {
45
+ Event::Start(tag) => match (section, tag.name()) {
46
+ (_, "numFmt") => {
47
+ if let (Some(id), Some(code)) = (tag.attr("numFmtId"), tag.attr("formatCode")) {
48
+ parts.codes.push((id.parse().unwrap_or(u32::MAX), code.into_owned()));
49
+ }
50
+ }
51
+ (_, "fonts") => section = Section::Fonts,
52
+ (_, "fills") => section = Section::Fills,
53
+ (_, "borders") => section = Section::Borders,
54
+ (_, "cellXfs") => section = Section::CellFormats,
55
+ (Section::Fonts, "font") => parts.fonts.push(Look::default()),
56
+ (Section::Fonts, name) => font_part(&mut parts.fonts, name, tag),
57
+ (Section::Fills, "fill") => parts.fills.push(None),
58
+ (Section::Fills, "fgColor") => {
59
+ if let Some(fill) = parts.fills.last_mut() {
60
+ *fill = tag.attr("rgb").map(|rgb| rgb.to_uppercase());
61
+ }
62
+ }
63
+ (Section::Borders, "border") => parts.borders.push(None),
64
+ (Section::Borders, name) if side.is_empty() => {
65
+ side = name;
66
+ if name == "left"
67
+ && let Some(border) = parts.borders.last_mut()
68
+ {
69
+ *border = tag.attr("style").map(Cow::into_owned);
70
+ }
71
+ }
72
+ (Section::CellFormats, "xf") => {
73
+ let id = |name| tag.parse::<usize>(name);
74
+ let number = tag.parse("numFmtId").unwrap_or(0);
75
+ let custom = parts.codes.iter().find(|(id, _)| *id == number);
76
+ let shown = custom.map_or_else(|| built_in(number), |(_, code)| classify(code));
77
+ let font = parts.fonts.get(id("fontId").unwrap_or(0));
78
+ let mut look = font.cloned().unwrap_or_default();
79
+ look.fill = parts.fills.get(id("fillId").unwrap_or(0)).cloned().flatten();
80
+ look.border = parts.borders.get(id("borderId").unwrap_or(0)).cloned().flatten();
81
+ look.number_format = custom.map_or_else(
82
+ || built_in_code(number).map_or_else(|| number.to_string(), str::to_owned),
83
+ |(_, code)| code.clone(),
84
+ );
85
+ formats.shown.push(shown);
86
+ formats.looks.push(Arc::new(look));
87
+ }
88
+ (Section::CellFormats, "alignment") => {
89
+ if let Some(look) = formats.looks.last_mut().map(Arc::make_mut) {
90
+ look.align = tag.attr("horizontal").map(Cow::into_owned);
91
+ look.valign = tag.attr("vertical").map(Cow::into_owned);
92
+ look.wrap = tag.on("wrapText");
93
+ }
94
+ }
95
+ _ => {}
96
+ },
97
+ Event::End(name) => match name {
98
+ "fonts" | "fills" | "borders" | "cellXfs" => section = Section::Other,
99
+ _ if name == side => side = "",
100
+ _ => {}
101
+ },
102
+ Event::Text(_) => {}
103
+ }
104
+ }
105
+ Ok(formats)
106
+ }
107
+
108
+ /// A property of the font being read.
109
+ fn font_part(fonts: &mut [Look], name: &str, tag: crate::xml::reader::Tag<'_>) {
110
+ let Some(font) = fonts.last_mut() else { return };
111
+ match name {
112
+ "b" => font.bold = tag.flag("val"),
113
+ "i" => font.italic = tag.flag("val"),
114
+ "u" => font.underline = tag.attr("val").is_none_or(|style| style != "none"),
115
+ "sz" => font.size = tag.parse("val").unwrap_or(0.0),
116
+ "name" => font.font = tag.attr("val").map(Cow::into_owned).unwrap_or_default(),
117
+ "color" => font.color = tag.attr("rgb").map(|rgb| rgb.to_uppercase()),
118
+ _ => {}
119
+ }
120
+ }
121
+
122
+ /// The codes of Excel's built-in number formats (ECMA-376 Part 1, 18.8.30).
123
+ const fn built_in_code(id: u32) -> Option<&'static str> {
124
+ Some(match id {
125
+ 0 => "General",
126
+ 1 => "0",
127
+ 2 => "0.00",
128
+ 3 => "#,##0",
129
+ 4 => "#,##0.00",
130
+ 9 => "0%",
131
+ 10 => "0.00%",
132
+ 11 => "0.00E+00",
133
+ 12 => "# ?/?",
134
+ 13 => "# ??/??",
135
+ 14 => "mm-dd-yy",
136
+ 15 => "d-mmm-yy",
137
+ 16 => "d-mmm",
138
+ 17 => "mmm-yy",
139
+ 18 => "h:mm AM/PM",
140
+ 19 => "h:mm:ss AM/PM",
141
+ 20 => "h:mm",
142
+ 21 => "h:mm:ss",
143
+ 22 => "m/d/yy h:mm",
144
+ 37 => "#,##0 ;(#,##0)",
145
+ 38 => "#,##0 ;[Red](#,##0)",
146
+ 39 => "#,##0.00;(#,##0.00)",
147
+ 40 => "#,##0.00;[Red](#,##0.00)",
148
+ 45 => "mm:ss",
149
+ 46 => "[h]:mm:ss",
150
+ 47 => "mmss.0",
151
+ 48 => "##0.0E+0",
152
+ 49 => "@",
153
+ _ => return None,
154
+ })
155
+ }
@@ -3,19 +3,21 @@
3
3
  //! digits), formulas without a cached result as `=FORMULA`, then the sheet's
4
4
  //! comments.
5
5
 
6
+ mod formats;
6
7
  #[cfg(test)]
7
8
  mod tests;
8
9
 
9
10
  use std::sync::Arc;
10
11
 
12
+ use self::formats::{Formats, formats};
11
13
  use super::Budget;
12
- use super::content::{Grid, Note, Sheet, Value};
13
- use super::numbers::{Shown, push_number, styles};
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};
20
+ use crate::xml::reader::{Event, Tag, XmlReader};
19
21
 
20
22
  /// A cell's `t` attribute.
21
23
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -51,13 +53,24 @@ impl Strings {
51
53
  }
52
54
  }
53
55
 
54
- /// Where a cell's text events go.
56
+ /// Where a cell's text events go, or a validation's formulas'.
55
57
  #[derive(Clone, Copy, PartialEq, Eq)]
56
58
  enum Into {
57
59
  Nowhere,
58
60
  Value,
59
61
  Formula,
60
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)>,
61
74
  }
62
75
 
63
76
  pub fn content(package: &Package<'_>, budget: &Budget<'_>) -> Result<Vec<Sheet>> {
@@ -67,21 +80,31 @@ pub fn content(package: &Package<'_>, budget: &Budget<'_>) -> Result<Vec<Sheet>>
67
80
  let (listed, date1904) = workbook(&text, &relationships, budget)?;
68
81
  let strings = package
69
82
  .parse_related(&relationships, "sharedStrings", |text| shared_strings(text, budget))?;
70
- let shown = package.parse_related(&relationships, "styles", |text| styles(text, budget))?;
71
- 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 };
72
85
  let mut sheets = Vec::with_capacity(listed.len());
73
86
  let mut scratch = String::new();
74
87
  for sheet in listed {
75
88
  budget.check()?;
76
89
  let Some(text) = package.text(&sheet.part)? else { continue };
77
- let cells = cells(&text, &book, &mut scratch, budget)?;
90
+ let (cells, parts) = cells(&text, &book, &mut scratch, budget)?;
78
91
  let comments = match package.related(&sheet.part, "comments")? {
79
92
  Some(part) => package
80
93
  .text(&part)?
81
94
  .map_or_else(|| Ok(Vec::new()), |text| comments(&text, budget))?,
82
95
  None => Vec::new(),
83
96
  };
84
- 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
+ });
85
108
  }
86
109
  Ok(sheets)
87
110
  }
@@ -155,8 +178,13 @@ struct Head {
155
178
  style: usize,
156
179
  }
157
180
 
158
- fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>) -> Result<Grid> {
159
- 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);
160
188
  let mut xml = XmlReader::new(text, budget.max_depth());
161
189
  let (mut row, mut next_row, mut next_column) = (0_u32, 0_u32, 0_u32);
162
190
  let (mut value, mut formula, mut inline) = (String::new(), String::new(), String::new());
@@ -166,10 +194,7 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
166
194
  match event {
167
195
  Event::Start(tag) => match tag.name() {
168
196
  "row" => {
169
- row = tag
170
- .attr("r")
171
- .and_then(|r| r.parse::<u32>().ok())
172
- .map_or(next_row, |r| r.saturating_sub(1));
197
+ row = tag.parse::<u32>("r").map_or(next_row, |r| r.saturating_sub(1));
173
198
  next_column = 0;
174
199
  }
175
200
  "c" => {
@@ -186,7 +211,7 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
186
211
  Some("d") => Kind::Iso,
187
212
  _ => Kind::Number,
188
213
  };
189
- let style = tag.attr("s").and_then(|s| s.parse().ok()).unwrap_or(0);
214
+ let style = tag.parse("s").unwrap_or(0);
190
215
  head = Some(Head { row: at_row, column, kind, style });
191
216
  value.clear();
192
217
  formula.clear();
@@ -198,17 +223,36 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
198
223
  into = Into::Inline;
199
224
  }
200
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()),
201
230
  _ => {}
202
231
  },
203
232
  Event::Text(text) => match into {
204
- Into::Value => value.push_str(&text),
233
+ Into::Value | Into::First | Into::Second => value.push_str(&text),
205
234
  Into::Formula => formula.push_str(&text),
206
235
  Into::Inline => inline.push_str(&unescape_xstring(&text)),
207
236
  Into::Nowhere => {}
208
237
  },
209
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()),
210
251
  Event::End("c") => {
211
252
  if let Some(head) = head.take() {
253
+ if head.style != 0 {
254
+ parts.styled.push((head.row, head.column, head.style));
255
+ }
212
256
  write_cell(&mut grid, &head, book, [&value, &formula, &inline], scratch);
213
257
  next_column = head.column + 1;
214
258
  }
@@ -217,7 +261,7 @@ fn cells(text: &str, book: &Book<'_>, scratch: &mut String, budget: &Budget<'_>)
217
261
  Event::End(_) => {}
218
262
  }
219
263
  }
220
- Ok(grid.finish())
264
+ Ok((grid.finish(), parts))
221
265
  }
222
266
 
223
267
  fn write_cell(
@@ -272,6 +316,48 @@ fn write_cell(
272
316
  grid.end_cell(head.row, head.column, kind);
273
317
  }
274
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
+
275
361
  /// The sheet's comments.
276
362
  fn comments(text: &str, budget: &Budget<'_>) -> Result<Vec<Note>> {
277
363
  let mut xml = XmlReader::new(text, budget.max_depth());
@@ -286,8 +372,7 @@ fn comments(text: &str, budget: &Budget<'_>) -> Result<Vec<Note>> {
286
372
  "author" => in_author = true,
287
373
  "comment" => {
288
374
  position = parse_cell_reference(&tag.attr("ref").unwrap_or_default()).ok();
289
- author_id =
290
- tag.attr("authorId").and_then(|id| id.parse().ok()).unwrap_or(usize::MAX);
375
+ author_id = tag.parse("authorId").unwrap_or(usize::MAX);
291
376
  body.clear();
292
377
  }
293
378
  "t" => in_text = true,
@@ -35,9 +35,13 @@ mod xlsx;
35
35
  pub(crate) use budget::Budget;
36
36
  pub use budget::Limits;
37
37
  pub use content::{
38
- 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,
39
40
  };
41
+ #[cfg(test)]
42
+ pub(crate) use image::png;
40
43
  pub use image::{Image, ImageFormat, ImageLocation};
44
+ pub(crate) use image::{PngColor, png_with_transparency};
41
45
 
42
46
  use crate::zip::reader::ZipReader;
43
47
  use crate::{Error, Result};
@@ -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.
@@ -40,6 +41,17 @@ impl Color {
40
41
  pub(crate) fn argb(self) -> String {
41
42
  format!("FF{:06X}", self.0)
42
43
  }
44
+
45
+ /// `#RRGGBB`, as SVG takes it.
46
+ pub(crate) fn hex(self) -> String {
47
+ format!("#{:06X}", self.0)
48
+ }
49
+
50
+ /// Red, green and blue bytes.
51
+ pub(crate) const fn bytes(self) -> [u8; 3] {
52
+ let [_, r, g, b] = self.0.to_be_bytes();
53
+ [r, g, b]
54
+ }
43
55
  }
44
56
 
45
57
  /// Horizontal alignment within the cell.
@@ -72,6 +84,26 @@ pub enum TextDirection {
72
84
  }
73
85
 
74
86
  impl TextDirection {
87
+ /// The direction text takes on its own, the way Word and browsers settle
88
+ /// a paragraph left to automatic: that of its first letter (UAX #9, P2).
89
+ /// `None` when it has no letter: digits, punctuation, nothing.
90
+ ///
91
+ /// ```
92
+ /// use imprimatur::style::TextDirection;
93
+ ///
94
+ /// assert_eq!(TextDirection::of("2024 شهادة"), Some(TextDirection::RightToLeft));
95
+ /// assert_eq!(TextDirection::of("ISO 9001 אישור"), Some(TextDirection::LeftToRight));
96
+ /// assert_eq!(TextDirection::of("1,250.50"), None);
97
+ /// ```
98
+ #[must_use]
99
+ pub fn of(text: &str) -> Option<Self> {
100
+ text.chars().find_map(|ch| match class(ch) {
101
+ Class::Right | Class::Arabic => Some(Self::RightToLeft),
102
+ Class::Left => Some(Self::LeftToRight),
103
+ Class::Number | Class::ArabicNumber | Class::Neutral => None,
104
+ })
105
+ }
106
+
75
107
  /// `readingOrder`: 1 left to right, 2 right to left (0 is context).
76
108
  pub(crate) const fn reading_order(self) -> &'static str {
77
109
  match self {
@@ -0,0 +1,60 @@
1
+ //! Column widths from the cells. There is no font to measure text with, so
2
+ //! a column is as wide as its widest cell's character count says, scaled by
3
+ //! that cell's font.
4
+
5
+ #[cfg(test)]
6
+ mod tests;
7
+
8
+ use std::collections::BTreeMap;
9
+
10
+ use super::cell::Cell;
11
+ use super::format::Font;
12
+ use super::worksheet::Worksheet;
13
+
14
+ /// Excel's widest column, in characters.
15
+ const MAX_WIDTH: f64 = 255.0;
16
+
17
+ /// The width each column without one of its own needs for its widest cell:
18
+ /// `(characters + 3) × points / 10`, a bold cell counting one and a half
19
+ /// times, at most 255. Formulas are left out: their results are unknown.
20
+ pub fn widths(sheet: &Worksheet, base: &Font) -> BTreeMap<u16, f64> {
21
+ let mut widths = BTreeMap::new();
22
+ for (_, cells) in sheet.rows() {
23
+ for (&column, entry) in cells {
24
+ if sheet.column_widths().contains_key(&column) {
25
+ continue;
26
+ }
27
+ let Some(characters) = characters(&entry.cell) else {
28
+ continue;
29
+ };
30
+ let font = usize::try_from(entry.style)
31
+ .ok()
32
+ .and_then(|style| style.checked_sub(1))
33
+ .and_then(|index| sheet.formats().get(index))
34
+ .map_or(base, |format| &format.font);
35
+ let bold = if font.bold { 1.5 } else { 1.0 };
36
+ #[expect(
37
+ clippy::cast_precision_loss,
38
+ reason = "a cell holds at most 32 767 characters"
39
+ )]
40
+ let width = (characters as f64 + 3.0) * f64::from(font.half_points(base)) / 20.0 * bold;
41
+ let width = (width.min(MAX_WIDTH) * 100.0).round() / 100.0;
42
+ let widest = widths.entry(column).or_insert(0.0_f64);
43
+ *widest = widest.max(width);
44
+ }
45
+ }
46
+ widths
47
+ }
48
+
49
+ /// How many characters a cell shows unformatted: a text's longest line,
50
+ /// a number's digits, `TRUE`, a date as `yyyy-mm-dd` and a time
51
+ /// `yyyy-mm-dd hh:mm:ss`.
52
+ fn characters(cell: &Cell) -> Option<usize> {
53
+ match cell {
54
+ Cell::Text(text) => text.split('\n').map(|line| line.chars().count()).max(),
55
+ Cell::Number(number) => Some(number.to_string().len()),
56
+ Cell::Bool(value) => Some(if *value { 4 } else { 5 }),
57
+ Cell::DateTime(date) => Some(if date.has_time() { 19 } else { 10 }),
58
+ Cell::Formula(_) => None,
59
+ }
60
+ }
@@ -90,6 +90,12 @@ impl DateTime {
90
90
  Self { hour: 0, minute: 0, second: 0, nanosecond: 0, ..self }
91
91
  }
92
92
 
93
+ /// Year, month and day.
94
+ #[must_use]
95
+ pub const fn year_month_day(&self) -> (u16, u8, u8) {
96
+ (self.year, self.month, self.day)
97
+ }
98
+
93
99
  /// Hours, minutes and seconds.
94
100
  #[must_use]
95
101
  pub const fn time_of_day(&self) -> (u8, u8, u8) {
@@ -147,7 +153,7 @@ const fn days_in_month(year: u16, month: u8) -> u8 {
147
153
 
148
154
  /// The calendar date `days` after 1970-01-01: the inverse of
149
155
  /// [`days_from_civil`] (Howard Hinnant's `civil_from_days`).
150
- const fn civil_from_days(days: i64) -> (i64, i64, i64) {
156
+ pub const fn civil_from_days(days: i64) -> (i64, i64, i64) {
151
157
  let days = days + 719_468;
152
158
  let era = days.div_euclid(146_097);
153
159
  let day_of_era = days - era * 146_097;