imprimatur 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/Cargo.lock +123 -3
  4. data/Cargo.toml +1 -1
  5. data/README.md +98 -20
  6. data/ext/imprimatur_native/cli/Cargo.toml +1 -1
  7. data/ext/imprimatur_native/core/Cargo.toml +6 -1
  8. data/ext/imprimatur_native/core/README.md +12 -3
  9. data/ext/imprimatur_native/core/src/archive.rs +3 -16
  10. data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
  11. data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
  12. data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
  13. data/ext/imprimatur_native/core/src/doc.rs +86 -2
  14. data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
  15. data/ext/imprimatur_native/core/src/docx.rs +91 -28
  16. data/ext/imprimatur_native/core/src/error.rs +12 -0
  17. data/ext/imprimatur_native/core/src/font.rs +229 -0
  18. data/ext/imprimatur_native/core/src/lib.rs +3 -0
  19. data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
  20. data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
  21. data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
  22. data/ext/imprimatur_native/core/src/pdf.rs +31 -3
  23. data/ext/imprimatur_native/core/src/read/content.rs +75 -2
  24. data/ext/imprimatur_native/core/src/read/json.rs +94 -7
  25. data/ext/imprimatur_native/core/src/read/numbers.rs +65 -42
  26. data/ext/imprimatur_native/core/src/read/ods.rs +1 -2
  27. data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
  28. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
  29. data/ext/imprimatur_native/core/src/read/xls.rs +1 -1
  30. data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
  31. data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -19
  32. data/ext/imprimatur_native/core/src/read.rs +4 -1
  33. data/ext/imprimatur_native/core/src/script.rs +116 -0
  34. data/ext/imprimatur_native/core/src/style.rs +21 -0
  35. data/ext/imprimatur_native/core/src/test_support.rs +3 -2
  36. data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
  37. data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
  38. data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
  39. data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
  40. data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
  41. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
  42. data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
  43. data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
  44. data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
  45. data/ext/imprimatur_native/ffi/src/archive.rs +1 -3
  46. data/ext/imprimatur_native/ffi/src/document.rs +48 -1
  47. data/ext/imprimatur_native/ffi/src/lib.rs +38 -1
  48. data/ext/imprimatur_native/ffi/src/read.rs +8 -11
  49. data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
  50. data/lib/imprimatur/document.rb +30 -6
  51. data/lib/imprimatur/reader.rb +2 -2
  52. data/lib/imprimatur/spreadsheet.rb +151 -0
  53. data/lib/imprimatur/version.rb +1 -1
  54. data/lib/imprimatur/xlsx.rb +25 -4
  55. data/lib/imprimatur.rb +15 -3
  56. metadata +11 -1
@@ -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
 
@@ -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;
@@ -132,7 +132,7 @@ impl Format {
132
132
  if length == 0 || length > MAX_FONT_NAME || name.chars().any(char::is_control) {
133
133
  return Err(Error::InvalidFontName(name));
134
134
  }
135
- self.font.name = (name != DEFAULT_FONT).then_some(name);
135
+ self.font.name = Some(name);
136
136
  Ok(self)
137
137
  }
138
138
 
@@ -147,7 +147,7 @@ impl Format {
147
147
  #[expect(clippy::cast_possible_truncation, reason = "a whole number from 2 to 818")]
148
148
  #[expect(clippy::cast_sign_loss, reason = "a whole number from 2 to 818")]
149
149
  let half_points = half_points as u16;
150
- self.font.half_points = (half_points != DEFAULT_HALF_POINTS).then_some(half_points);
150
+ self.font.half_points = Some(half_points);
151
151
  Ok(self)
152
152
  }
153
153
 
@@ -213,13 +213,29 @@ pub struct Font {
213
213
  }
214
214
 
215
215
  impl Font {
216
- pub(crate) fn name(&self) -> &str {
217
- self.name.as_deref().unwrap_or(DEFAULT_FONT)
216
+ /// No name or size of its own: the workbook's, Calibri 11 by default.
217
+ pub(crate) const BASE: Self = Self {
218
+ bold: false,
219
+ italic: false,
220
+ underline: false,
221
+ color: None,
222
+ name: None,
223
+ half_points: None,
224
+ };
225
+
226
+ /// Its name, or else the workbook's `base` one.
227
+ pub(crate) fn name<'a>(&'a self, base: &'a Self) -> &'a str {
228
+ self.name.as_deref().or(base.name.as_deref()).unwrap_or(DEFAULT_FONT)
229
+ }
230
+
231
+ /// Its size in half points, or else the workbook's `base` one.
232
+ pub(crate) fn half_points(&self, base: &Self) -> u16 {
233
+ self.half_points.or(base.half_points).unwrap_or(DEFAULT_HALF_POINTS)
218
234
  }
219
235
 
220
236
  /// The size in points as `<sz>` writes it: `11`, `10.5`.
221
- pub(crate) fn size(&self) -> String {
222
- let half_points = self.half_points.unwrap_or(DEFAULT_HALF_POINTS);
237
+ pub(crate) fn size(&self, base: &Self) -> String {
238
+ let half_points = self.half_points(base);
223
239
  let whole = half_points / 2;
224
240
  if half_points.is_multiple_of(2) { whole.to_string() } else { format!("{whole}.5") }
225
241
  }
@@ -1,10 +1,13 @@
1
1
  //! The parts of an `.xlsx` package and the relationships between them.
2
2
 
3
+ use std::collections::BTreeMap;
3
4
  use std::fmt::Write as _;
4
5
  use std::io::Write;
5
6
 
7
+ use super::autofit;
6
8
  use super::cell::{Cell, cell_reference, push_cell_reference};
7
9
  use super::comment;
10
+ use super::format::Font;
8
11
  use super::styles::Stylesheet;
9
12
  use super::text::{SharedStrings, escape, needs_preserve};
10
13
  use super::validation;
@@ -28,7 +31,9 @@ const DRAWING_ID: &str = "rId2";
28
31
  /// compressed and dropped before the next is built, so only one sheet's XML
29
32
  /// is in memory at a time; the styles and the shared string table, complete
30
33
  /// only after every sheet, follow them.
31
- pub fn write<W: Write>(sheets: &[Worksheet], out: W) -> Result<W> {
34
+ /// `font` is the workbook's: unformatted cells', and the formats' with no
35
+ /// name or size.
36
+ pub fn write<W: Write>(sheets: &[Worksheet], font: &Font, out: W) -> Result<W> {
32
37
  if sheets.is_empty() {
33
38
  return Err(Error::EmptyWorkbook);
34
39
  }
@@ -57,7 +62,7 @@ pub fn write<W: Write>(sheets: &[Worksheet], out: W) -> Result<W> {
57
62
  for (number, sheet) in (1..).zip(sheets) {
58
63
  let remap = styles.add_sheet(sheet)?;
59
64
  set(&mut name, format_args!("xl/worksheets/sheet{number}.xml"));
60
- zip.add(&name, worksheet(sheet, &remap, &mut strings).as_bytes())?;
65
+ zip.add(&name, worksheet(sheet, &remap, &mut strings, font).as_bytes())?;
61
66
  let comments = sheet.comments();
62
67
  if comments.is_empty() {
63
68
  continue;
@@ -74,7 +79,7 @@ pub fn write<W: Write>(sheets: &[Worksheet], out: W) -> Result<W> {
74
79
  next_block += comment::blocks_for(comments.len());
75
80
  }
76
81
  debug_assert_eq!(has_strings, !strings.is_empty(), "the text scan matches the table");
77
- zip.add("xl/styles.xml", styles.to_xml().as_bytes())?;
82
+ zip.add("xl/styles.xml", styles.to_xml(font).as_bytes())?;
78
83
  if has_strings {
79
84
  zip.add("xl/sharedStrings.xml", strings.to_xml().as_bytes())?;
80
85
  }
@@ -137,7 +142,12 @@ fn workbook(sheets: &[Worksheet]) -> String {
137
142
 
138
143
  /// `remap` turns the sheet's own style ids into the workbook's. The cell
139
144
  /// loop formats into three scratch buffers instead of allocating per cell.
140
- fn worksheet<'a>(sheet: &'a Worksheet, remap: &[u32], strings: &mut SharedStrings<'a>) -> String {
145
+ fn worksheet<'a>(
146
+ sheet: &'a Worksheet,
147
+ remap: &[u32],
148
+ strings: &mut SharedStrings<'a>,
149
+ font: &Font,
150
+ ) -> String {
141
151
  let mut xml = XmlWriter::new();
142
152
  xml.open("worksheet", &[("xmlns", MAIN_NS), ("xmlns:r", opc::RELATIONSHIPS)]);
143
153
  xml.empty("dimension", &[("ref", &dimension(sheet))]);
@@ -146,7 +156,7 @@ fn worksheet<'a>(sheet: &'a Worksheet, remap: &[u32], strings: &mut SharedString
146
156
  xml.empty("sheetView", &[("rightToLeft", "1"), ("workbookViewId", "0")]);
147
157
  xml.close("sheetViews");
148
158
  }
149
- write_columns(&mut xml, sheet);
159
+ write_columns(&mut xml, sheet, font);
150
160
  xml.open("sheetData", &[]);
151
161
  let (mut reference, mut style, mut value) = (String::new(), String::new(), String::new());
152
162
  for (row, cells) in sheet.rows() {
@@ -216,13 +226,17 @@ fn set(buffer: &mut String, value: impl std::fmt::Display) {
216
226
  }
217
227
 
218
228
  /// `<cols>`: one `<col>` per run of adjacent columns with the same width.
219
- fn write_columns(xml: &mut XmlWriter, sheet: &Worksheet) {
220
- let widths = sheet.column_widths();
229
+ /// The columns' widths: those set, and for an autofit sheet those its cells
230
+ /// need.
231
+ fn write_columns(xml: &mut XmlWriter, sheet: &Worksheet, font: &Font) {
232
+ let mut widths =
233
+ if sheet.is_autofit() { autofit::widths(sheet, font) } else { BTreeMap::new() };
234
+ widths.extend(sheet.column_widths());
221
235
  if widths.is_empty() {
222
236
  return;
223
237
  }
224
238
  let mut runs: Vec<(u16, u16, f64)> = Vec::new();
225
- for (&column, &width) in widths {
239
+ for (column, width) in widths {
226
240
  match runs.last_mut() {
227
241
  Some((_, last, run_width))
228
242
  if *last + 1 == column && run_width.to_bits() == width.to_bits() =>
@@ -70,7 +70,8 @@ impl Stylesheet {
70
70
  Ok(remap)
71
71
  }
72
72
 
73
- pub fn to_xml(&self) -> String {
73
+ /// The stylesheet, fonts without a name or size of their own in `base`.
74
+ pub fn to_xml(&self, base: &Font) -> String {
74
75
  let mut number_formats = Table::<&str>::new(FIRST_CUSTOM_NUMBER_FORMAT);
75
76
  let mut fonts = Table::<&Font>::new(1);
76
77
  let mut fills = Table::<Color>::new(2);
@@ -101,7 +102,7 @@ impl Stylesheet {
101
102
  }
102
103
  xml.close("numFmts");
103
104
  }
104
- write_fonts(&mut xml, &fonts.order);
105
+ write_fonts(&mut xml, &fonts.order, base);
105
106
  write_fills(&mut xml, &fills.order);
106
107
  write_borders(&mut xml, &borders.order);
107
108
  xml.open("cellStyleXfs", &[("count", "1")]);
@@ -131,24 +132,24 @@ impl Stylesheet {
131
132
  }
132
133
  }
133
134
 
134
- fn write_fonts(xml: &mut XmlWriter, fonts: &[&Font]) {
135
+ /// Font 0, the one of unformatted cells, is `base`.
136
+ fn write_fonts(xml: &mut XmlWriter, fonts: &[&Font], base: &Font) {
135
137
  let count = (fonts.len() + 1).to_string();
136
138
  xml.open("fonts", &[("count", &count)]);
137
- let default = Font::default();
138
- for font in std::iter::once(&default).chain(fonts.iter().copied()) {
139
+ for font in std::iter::once(base).chain(fonts.iter().copied()) {
139
140
  xml.open("font", &[]);
140
141
  for (on, tag) in [(font.bold, "b"), (font.italic, "i"), (font.underline, "u")] {
141
142
  if on {
142
143
  xml.empty(tag, &[]);
143
144
  }
144
145
  }
145
- xml.empty("sz", &[("val", &font.size())]);
146
+ xml.empty("sz", &[("val", &font.size(base))]);
146
147
  if let Some(color) = font.color {
147
148
  xml.empty("color", &[("rgb", &color.argb())]);
148
149
  }
149
- xml.empty("name", &[("val", font.name())]);
150
+ xml.empty("name", &[("val", font.name(base))]);
150
151
  // The family is a fallback hint; only Calibri's is known (2, swiss).
151
- if font.name() == DEFAULT_FONT {
152
+ if font.name(base) == DEFAULT_FONT {
152
153
  xml.empty("family", &[("val", "2")]);
153
154
  }
154
155
  xml.close("font");
@@ -6,6 +6,7 @@ use std::sync::Arc;
6
6
 
7
7
  use super::text::check_length;
8
8
  use super::{Cell, CellRange, Comment, Format, Validation};
9
+ use crate::script::Counts;
9
10
  use crate::{Error, Result};
10
11
 
11
12
  /// Rows in a sheet (Excel 2007 and later).
@@ -40,7 +41,9 @@ pub struct Worksheet {
40
41
  /// The style of an unformatted date, then of an unformatted date and time.
41
42
  date_styles: [Option<u32>; 2],
42
43
  widths: BTreeMap<u16, f64>,
43
- right_to_left: bool,
44
+ autofit: bool,
45
+ /// As set; `None` to follow the sheet's text.
46
+ right_to_left: Option<bool>,
44
47
  validations: Vec<(CellRange, Validation)>,
45
48
  comments: BTreeMap<(u32, u16), Comment>,
46
49
  }
@@ -55,7 +58,8 @@ impl Worksheet {
55
58
  format_ids: HashMap::new(),
56
59
  date_styles: [None; 2],
57
60
  widths: BTreeMap::new(),
58
- right_to_left: false,
61
+ autofit: false,
62
+ right_to_left: None,
59
63
  validations: Vec::new(),
60
64
  comments: BTreeMap::new(),
61
65
  }
@@ -174,16 +178,40 @@ impl Worksheet {
174
178
  Ok(self)
175
179
  }
176
180
 
177
- /// Lays the sheet out right to left: column `A` on the right, as Excel
178
- /// shows sheets for Arabic and Hebrew.
181
+ /// Sizes the columns without a width of their own to their widest cell,
182
+ /// estimated from its characters and font as
183
+ /// `(characters + 3) × points / 10`, bold one and a half times wider.
184
+ pub const fn set_autofit(&mut self, autofit: bool) -> &mut Self {
185
+ self.autofit = autofit;
186
+ self
187
+ }
188
+
189
+ #[must_use]
190
+ pub const fn is_autofit(&self) -> bool {
191
+ self.autofit
192
+ }
193
+
194
+ /// Lays the sheet out right to left, column `A` on the right, as Excel
195
+ /// shows sheets for Arabic and Hebrew; or left to right. Without a
196
+ /// call, a sheet is right to left when most of the letters of its name
197
+ /// and text cells are.
179
198
  pub const fn set_right_to_left(&mut self, right_to_left: bool) -> &mut Self {
180
- self.right_to_left = right_to_left;
199
+ self.right_to_left = Some(right_to_left);
181
200
  self
182
201
  }
183
202
 
184
203
  #[must_use]
185
- pub const fn is_right_to_left(&self) -> bool {
186
- self.right_to_left
204
+ pub fn is_right_to_left(&self) -> bool {
205
+ self.right_to_left.unwrap_or_else(|| {
206
+ let mut counts = Counts::default();
207
+ counts.add(&self.name);
208
+ for entry in self.rows.values().flat_map(BTreeMap::values) {
209
+ if let Cell::Text(text) = &entry.cell {
210
+ counts.add(text);
211
+ }
212
+ }
213
+ counts.right_to_left()
214
+ })
187
215
  }
188
216
 
189
217
  /// Applies a rule to the cells of `range`, whether or not they hold values.
@@ -1,5 +1,6 @@
1
1
  //! `SpreadsheetML` (`.xlsx`) workbooks, per ECMA-376 Part 1.
2
2
 
3
+ mod autofit;
3
4
  mod cell;
4
5
  mod comment;
5
6
  mod date;
@@ -22,6 +23,9 @@ pub(crate) use cell::push_cell_reference;
22
23
  pub use cell::{Cell, cell_reference, column_name};
23
24
  pub use comment::Comment;
24
25
  pub use date::DateTime;
26
+ #[cfg(feature = "archive")]
27
+ pub(crate) use date::civil_from_days;
28
+ use format::Font;
25
29
  pub use format::{Border, Format, VerticalAlign};
26
30
  pub use range::{CellRange, parse_cell_reference};
27
31
  pub use validation::{Alert, Validation};
@@ -36,12 +40,44 @@ const MAX_SHEET_NAME: usize = 31;
36
40
  #[derive(Debug, Default)]
37
41
  pub struct Workbook {
38
42
  sheets: Vec<Worksheet>,
43
+ /// The font of unformatted cells, and of formats with no name or size.
44
+ font: Font,
45
+ /// Whether new sheets size their columns to their cells.
46
+ autofit: bool,
39
47
  }
40
48
 
41
49
  impl Workbook {
42
50
  #[must_use]
43
51
  pub const fn new() -> Self {
44
- Self { sheets: Vec::new() }
52
+ Self { sheets: Vec::new(), font: Font::BASE, autofit: false }
53
+ }
54
+
55
+ /// The font of cells without a format, and of formats that name no font:
56
+ /// Calibri unless set.
57
+ ///
58
+ /// # Errors
59
+ /// [`Error::InvalidFontName`] for an empty name, one longer than 31
60
+ /// characters or with a control character.
61
+ pub fn set_font_name(&mut self, name: impl Into<String>) -> Result<&mut Self> {
62
+ self.font.name = Format::new().font_name(name)?.font.name;
63
+ Ok(self)
64
+ }
65
+
66
+ /// The size of cells without a format, and of formats that set none: 11
67
+ /// points unless set.
68
+ ///
69
+ /// # Errors
70
+ /// [`Error::InvalidFontSize`] outside 1 to 409 points in steps of 0.5.
71
+ pub fn set_font_size(&mut self, points: f64) -> Result<&mut Self> {
72
+ self.font.half_points = Format::new().font_size(points)?.font.half_points;
73
+ Ok(self)
74
+ }
75
+
76
+ /// Whether the sheets added from now on size their columns to their
77
+ /// cells, as [`Worksheet::set_autofit`] does.
78
+ pub const fn set_autofit(&mut self, autofit: bool) -> &mut Self {
79
+ self.autofit = autofit;
80
+ self
45
81
  }
46
82
 
47
83
  /// Adds a sheet after the existing ones and returns it for writing.
@@ -56,7 +92,8 @@ impl Workbook {
56
92
  if self.sheets.iter().any(|sheet| sheet.name().to_lowercase() == folded) {
57
93
  return Err(Error::DuplicateSheetName(name));
58
94
  }
59
- Ok(self.sheets.push_mut(Worksheet::new(name)))
95
+ let autofit = self.autofit;
96
+ Ok(self.sheets.push_mut(Worksheet::new(name)).set_autofit(autofit))
60
97
  }
61
98
 
62
99
  #[must_use]
@@ -73,7 +110,7 @@ impl Workbook {
73
110
  /// # Errors
74
111
  /// [`Error::EmptyWorkbook`] without sheets, [`Error::Io`] when `out` fails.
75
112
  pub fn write_to<W: Write>(&self, out: W) -> Result<W> {
76
- package::write(&self.sheets, out)
113
+ package::write(&self.sheets, &self.font, out)
77
114
  }
78
115
 
79
116
  /// The `.xlsx` package as bytes.
@@ -57,6 +57,16 @@ impl<'a> Tag<'a> {
57
57
  }
58
58
  }
59
59
 
60
+ /// The attribute parsed: `None` when it is missing or does not parse.
61
+ pub fn parse<T: std::str::FromStr>(&self, local: &str) -> Option<T> {
62
+ self.attr(local)?.parse().ok()
63
+ }
64
+
65
+ /// Whether the attribute is there and on: `1`, `true` or `on`.
66
+ pub fn on(&self, local: &str) -> bool {
67
+ self.attr(local).is_some() && self.flag(local)
68
+ }
69
+
60
70
  /// `w:val`-style booleans: present without a value, `1`, `true` or
61
71
  /// `on` are true; Word writes `w:val="0"` to switch a property off.
62
72
  pub fn flag(&self, local: &str) -> bool {
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "imprimatur_native"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Ruby bindings for the imprimatur crate"
5
5
  publish = false
6
6
  edition.workspace = true
@@ -15,7 +15,8 @@ crate-type = ["cdylib"]
15
15
  workspace = true
16
16
 
17
17
  [dependencies]
18
- imprimatur = { workspace = true, features = ["archive", "json"] }
18
+ # Gems ship precompiled, so they carry every feature of the crate.
19
+ imprimatur = { workspace = true, features = ["archive", "fonts", "json"] }
19
20
  serde_json.workspace = true
20
21
  magnus.workspace = true
21
22
  rb-sys.workspace = true
@@ -45,9 +45,7 @@ impl ZipWriter {
45
45
  data: RString,
46
46
  comment: Option<String>,
47
47
  ) -> Result<(), Error> {
48
- // SAFETY: the bytes are copied out at once, while the GVL is held.
49
- #[allow(unsafe_code)]
50
- let data = unsafe { data.as_slice() }.to_vec();
48
+ let data = crate::bytes(data);
51
49
  let mut slot = borrow(ruby, &rb_self.0, "zip writer")?;
52
50
  let writer = slot.as_mut().ok_or_else(|| finished(ruby))?;
53
51
  without_gvl(ruby, || writer.add(&name, &data, comment.as_deref()))?.or_raise(ruby)