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
@@ -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.1.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 = ["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
@@ -0,0 +1,119 @@
1
+ //! `Imprimatur::Native::ZipWriter` and `ZipReader`: zip archives of any
2
+ //! files, compressed and read without the GVL.
3
+
4
+ use std::cell::RefCell;
5
+ use std::fs::File;
6
+
7
+ use imprimatur::archive::{Archive, Writer};
8
+ use magnus::{Error, RString, Ruby};
9
+
10
+ use crate::blocking::without_gvl;
11
+ use crate::{OrRaise, borrow};
12
+
13
+ #[magnus::wrap(class = "Imprimatur::Native::ZipWriter", free_immediately, size)]
14
+ pub struct ZipWriter(RefCell<Option<Writer<File>>>);
15
+
16
+ fn finished(ruby: &Ruby) -> Error {
17
+ Error::new(ruby.exception_runtime_error(), "the archive is finished")
18
+ }
19
+
20
+ #[expect(clippy::needless_pass_by_value, reason = "magnus passes Ruby arguments as owned values")]
21
+ impl ZipWriter {
22
+ /// A new archive at `path`.
23
+ pub fn new(ruby: &Ruby, path: String) -> Result<Self, Error> {
24
+ Ok(Self(RefCell::new(Some(Writer::create(path).or_raise(ruby)?))))
25
+ }
26
+
27
+ /// Adds the file at `source` as `name`, streamed.
28
+ pub fn add_file(
29
+ ruby: &Ruby,
30
+ rb_self: &Self,
31
+ name: String,
32
+ source: String,
33
+ comment: Option<String>,
34
+ ) -> Result<(), Error> {
35
+ let mut slot = borrow(ruby, &rb_self.0, "zip writer")?;
36
+ let writer = slot.as_mut().ok_or_else(|| finished(ruby))?;
37
+ without_gvl(ruby, || writer.add_file(&name, &source, comment.as_deref()))?.or_raise(ruby)
38
+ }
39
+
40
+ /// Adds `data` as `name`.
41
+ pub fn add(
42
+ ruby: &Ruby,
43
+ rb_self: &Self,
44
+ name: String,
45
+ data: RString,
46
+ comment: Option<String>,
47
+ ) -> Result<(), Error> {
48
+ let data = crate::bytes(data);
49
+ let mut slot = borrow(ruby, &rb_self.0, "zip writer")?;
50
+ let writer = slot.as_mut().ok_or_else(|| finished(ruby))?;
51
+ without_gvl(ruby, || writer.add(&name, &data, comment.as_deref()))?.or_raise(ruby)
52
+ }
53
+
54
+ /// The archive's own comment.
55
+ pub fn set_comment(ruby: &Ruby, rb_self: &Self, comment: String) -> Result<(), Error> {
56
+ let mut slot = borrow(ruby, &rb_self.0, "zip writer")?;
57
+ slot.as_mut().ok_or_else(|| finished(ruby))?.set_comment(&comment).or_raise(ruby)
58
+ }
59
+
60
+ /// Writes the archive's directory; the writer takes no more entries.
61
+ pub fn finish(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
62
+ let writer =
63
+ borrow(ruby, &rb_self.0, "zip writer")?.take().ok_or_else(|| finished(ruby))?;
64
+ without_gvl(ruby, || writer.finish())?.or_raise(ruby).map(drop)
65
+ }
66
+ }
67
+
68
+ /// An entry for Ruby: name, size, compressed size, CRC-32, comment.
69
+ type EntryRow = (String, u64, u64, u32, String);
70
+
71
+ #[magnus::wrap(class = "Imprimatur::Native::ZipReader", free_immediately, size)]
72
+ pub struct ZipReader(RefCell<Archive<File>>);
73
+
74
+ #[expect(clippy::needless_pass_by_value, reason = "magnus passes Ruby arguments as owned values")]
75
+ impl ZipReader {
76
+ /// The archive at `path`.
77
+ pub fn new(ruby: &Ruby, path: String) -> Result<Self, Error> {
78
+ Ok(Self(RefCell::new(Archive::open(path).or_raise(ruby)?)))
79
+ }
80
+
81
+ pub fn names(ruby: &Ruby, rb_self: &Self) -> Result<Vec<String>, Error> {
82
+ Ok(borrow(ruby, &rb_self.0, "zip archive")?.names())
83
+ }
84
+
85
+ /// Each entry's name, size, compressed size, CRC-32 and comment.
86
+ pub fn entries(ruby: &Ruby, rb_self: &Self) -> Result<Vec<EntryRow>, Error> {
87
+ let entries = borrow(ruby, &rb_self.0, "zip archive")?.entries().or_raise(ruby)?;
88
+ Ok(entries
89
+ .into_iter()
90
+ .map(|entry| {
91
+ (entry.name, entry.size, entry.compressed_size, entry.crc32, entry.comment)
92
+ })
93
+ .collect())
94
+ }
95
+
96
+ /// The entry's bytes, `nil` when there is none.
97
+ pub fn read(
98
+ ruby: &Ruby,
99
+ rb_self: &Self,
100
+ name: String,
101
+ limit: u64,
102
+ ) -> Result<Option<RString>, Error> {
103
+ let mut archive = borrow(ruby, &rb_self.0, "zip archive")?;
104
+ let data = without_gvl(ruby, || archive.read(&name, limit))?.or_raise(ruby)?;
105
+ Ok(data.map(|data| ruby.str_from_slice(&data)))
106
+ }
107
+
108
+ /// Writes the entry to `path`; whether there was one.
109
+ pub fn extract(
110
+ ruby: &Ruby,
111
+ rb_self: &Self,
112
+ name: String,
113
+ path: String,
114
+ limit: u64,
115
+ ) -> Result<bool, Error> {
116
+ let mut archive = borrow(ruby, &rb_self.0, "zip archive")?;
117
+ without_gvl(ruby, || archive.extract(&name, &path, limit))?.or_raise(ruby)
118
+ }
119
+ }
@@ -0,0 +1,63 @@
1
+ //! Work run without the GVL that cannot stop midway (compressing a file):
2
+ //! Ruby's other threads keep running, and an interrupt waits for the end.
3
+
4
+ use std::ffi::c_void;
5
+ use std::panic::{AssertUnwindSafe, catch_unwind};
6
+
7
+ use magnus::{Error, Ruby};
8
+
9
+ /// Runs interrupted before they start are retried this many times.
10
+ pub const MAX_ATTEMPTS: usize = 64;
11
+
12
+ /// The job, and what it returned (or that it panicked).
13
+ struct Slot<F, T> {
14
+ job: Option<F>,
15
+ result: Option<std::thread::Result<T>>,
16
+ }
17
+
18
+ /// Runs `job` on this thread with the GVL released.
19
+ pub fn without_gvl<F: FnOnce() -> T, T>(ruby: &Ruby, job: F) -> Result<T, Error> {
20
+ let mut slot = Slot { job: Some(job), result: None };
21
+ let pointer: *mut Slot<F, T> = &raw mut slot;
22
+ for _ in 0..MAX_ATTEMPTS {
23
+ // SAFETY: `slot` outlives the call, which returns once `run` has
24
+ // finished or never started; `run` touches no Ruby object. Without
25
+ // an unblocking function the job runs to its end; the `2` variant
26
+ // raises no pending interrupt across these Rust frames.
27
+ #[allow(unsafe_code)]
28
+ unsafe {
29
+ rb_sys::rb_thread_call_without_gvl2(
30
+ Some(run::<F, T>),
31
+ pointer.cast(),
32
+ None,
33
+ std::ptr::null_mut(),
34
+ );
35
+ }
36
+ match slot.result.take() {
37
+ Some(Ok(value)) => return Ok(value),
38
+ Some(Err(_)) => {
39
+ return Err(Error::new(
40
+ ruby.exception_runtime_error(),
41
+ "imprimatur failed on this archive",
42
+ ));
43
+ }
44
+ // An interrupt was pending, so the job never started: let Ruby
45
+ // handle it (it may raise), then start.
46
+ None => ruby.thread_check_ints()?,
47
+ }
48
+ }
49
+ Err(Error::new(ruby.exception_runtime_error(), "interrupted before it could start"))
50
+ }
51
+
52
+ /// The job itself, without the GVL; a panic becomes an error, not an
53
+ /// abort at this C boundary.
54
+ #[allow(unsafe_code)]
55
+ unsafe extern "C" fn run<F: FnOnce() -> T, T>(data: *mut c_void) -> *mut c_void {
56
+ // SAFETY: `data` is the `Slot` without_gvl passed, alive and not
57
+ // otherwise borrowed until this returns.
58
+ let slot = unsafe { &mut *data.cast::<Slot<F, T>>() };
59
+ if let Some(job) = slot.job.take() {
60
+ slot.result = Some(catch_unwind(AssertUnwindSafe(job)));
61
+ }
62
+ std::ptr::null_mut()
63
+ }