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
@@ -2,7 +2,7 @@
2
2
 
3
3
  use std::cell::RefCell;
4
4
 
5
- use imprimatur::doc::{Document, PageSize, Paragraph, Run, Table};
5
+ use imprimatur::doc::{Document, PageSize, Paragraph, Picture, Run, Table};
6
6
  use imprimatur::style::Color;
7
7
  use magnus::{Error, RModule, RString, Ruby, function, method, prelude::*};
8
8
 
@@ -40,10 +40,54 @@ impl NativeDocument {
40
40
  &size,
41
41
  &[("a4", PageSize::A4), ("letter", PageSize::Letter)],
42
42
  )?;
43
+ Self::page(ruby, rb_self, size, page_numbers)
44
+ }
45
+
46
+ /// A page `width` by `height` points.
47
+ fn set_page_points(
48
+ ruby: &Ruby,
49
+ rb_self: &Self,
50
+ width: f64,
51
+ height: f64,
52
+ page_numbers: bool,
53
+ ) -> Result<(), Error> {
54
+ let size = PageSize::custom(width, height).or_raise(ruby)?;
55
+ Self::page(ruby, rb_self, size, page_numbers)
56
+ }
57
+
58
+ fn page(ruby: &Ruby, rb_self: &Self, size: PageSize, page_numbers: bool) -> Result<(), Error> {
43
59
  borrow(ruby, &rb_self.0, "document")?.set_page_size(size).set_page_numbers(page_numbers);
44
60
  Ok(())
45
61
  }
46
62
 
63
+ fn set_margin(ruby: &Ruby, rb_self: &Self, points: f64) -> Result<(), Error> {
64
+ borrow(ruby, &rb_self.0, "document")?.set_margin(points).or_raise(ruby)?;
65
+ Ok(())
66
+ }
67
+
68
+ /// A PNG or JPEG from its bytes, `width` and `height` in points.
69
+ fn picture(
70
+ ruby: &Ruby,
71
+ rb_self: &Self,
72
+ data: RString,
73
+ width: Option<f64>,
74
+ height: Option<f64>,
75
+ align: Option<String>,
76
+ ) -> Result<(), Error> {
77
+ let mut picture = Picture::new(crate::bytes(data)).or_raise(ruby)?;
78
+ if let Some(width) = width {
79
+ picture = picture.width(width).or_raise(ruby)?;
80
+ }
81
+ if let Some(height) = height {
82
+ picture = picture.height(height).or_raise(ruby)?;
83
+ }
84
+ if let Some(align) = align {
85
+ picture = picture.align(keyword(ruby, "align", &align, &ALIGNS)?);
86
+ }
87
+ borrow(ruby, &rb_self.0, "document")?.picture(picture);
88
+ Ok(())
89
+ }
90
+
47
91
  fn heading(ruby: &Ruby, rb_self: &Self, level: u8, text: String) -> Result<(), Error> {
48
92
  borrow(ruby, &rb_self.0, "document")?.heading(level, text).or_raise(ruby)?;
49
93
  Ok(())
@@ -128,6 +172,9 @@ pub fn define(ruby: &Ruby, native: RModule) -> Result<(), Error> {
128
172
  let class = native.define_class("Document", ruby.class_object())?;
129
173
  class.define_singleton_method("new", function!(NativeDocument::new, 0))?;
130
174
  class.define_method("set_page", method!(NativeDocument::set_page, 2))?;
175
+ class.define_method("set_page_points", method!(NativeDocument::set_page_points, 3))?;
176
+ class.define_method("set_margin", method!(NativeDocument::set_margin, 1))?;
177
+ class.define_method("picture", method!(NativeDocument::picture, 4))?;
131
178
  class.define_method("heading", method!(NativeDocument::heading, 2))?;
132
179
  class.define_method("paragraph", method!(NativeDocument::paragraph, 3))?;
133
180
  class.define_method("table", method!(NativeDocument::table, 2))?;
@@ -3,9 +3,12 @@
3
3
  //! [`imprimatur::xlsx::Format`]. The Ruby DSL in `lib/imprimatur/xlsx.rb`
4
4
  //! drives them; sheets cross as indices.
5
5
 
6
+ mod archive;
7
+ mod blocking;
6
8
  mod convert;
7
9
  mod document;
8
10
  mod read;
11
+ mod sheets;
9
12
  mod style;
10
13
  mod validation;
11
14
 
@@ -129,6 +132,31 @@ impl NativeWorkbook {
129
132
  Ok(())
130
133
  }
131
134
 
135
+ /// The workbook's own font, and whether the sheets it adds autofit.
136
+ fn configure(
137
+ ruby: &Ruby,
138
+ rb_self: &Self,
139
+ font: Option<String>,
140
+ size: Option<f64>,
141
+ autofit: bool,
142
+ ) -> Result<(), Error> {
143
+ let mut book = borrow(ruby, &rb_self.0, "workbook")?;
144
+ if let Some(font) = font {
145
+ book.set_font_name(font).or_raise(ruby)?;
146
+ }
147
+ if let Some(size) = size {
148
+ book.set_font_size(size).or_raise(ruby)?;
149
+ }
150
+ book.set_autofit(autofit);
151
+ Ok(())
152
+ }
153
+
154
+ fn set_autofit(ruby: &Ruby, rb_self: &Self, sheet: usize, autofit: bool) -> Result<(), Error> {
155
+ let mut book = borrow(ruby, &rb_self.0, "workbook")?;
156
+ sheet_mut(ruby, &mut book, sheet)?.set_autofit(autofit);
157
+ Ok(())
158
+ }
159
+
132
160
  fn set_right_to_left(
133
161
  ruby: &Ruby,
134
162
  rb_self: &Self,
@@ -176,6 +204,14 @@ impl std::io::Write for RubyBytes {
176
204
 
177
205
  /// Mutable access to a wrapped value; Ruby code that calls back into the
178
206
  /// same object mid-call gets a `RuntimeError`, not a panic.
207
+ /// A Ruby String's bytes, copied out.
208
+ fn bytes(string: RString) -> Vec<u8> {
209
+ // SAFETY: the bytes are copied out at once, while the GVL is held and
210
+ // before any Ruby code can run and move or free the string.
211
+ #[allow(unsafe_code)]
212
+ unsafe { string.as_slice() }.to_vec()
213
+ }
214
+
179
215
  fn borrow<'a, T>(ruby: &Ruby, cell: &'a RefCell<T>, what: &str) -> Result<RefMut<'a, T>, Error> {
180
216
  cell.try_borrow_mut().map_err(|_| {
181
217
  Error::new(
@@ -282,8 +318,21 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
282
318
 
283
319
  let native = imprimatur.define_module("Native")?;
284
320
  native.define_singleton_method("markdown", function!(read::markdown_native, 4))?;
285
- native.define_singleton_method("content", function!(read::content_native, 4))?;
321
+ native.define_singleton_method("content", function!(read::content_native, 5))?;
286
322
  native.define_singleton_method("images", function!(read::images_native, 4))?;
323
+ native.define_singleton_method("sheets", function!(sheets::sheets_native, 5))?;
324
+ let zip_writer = native.define_class("ZipWriter", ruby.class_object())?;
325
+ zip_writer.define_singleton_method("new", function!(archive::ZipWriter::new, 1))?;
326
+ zip_writer.define_method("add_file", method!(archive::ZipWriter::add_file, 3))?;
327
+ zip_writer.define_method("add", method!(archive::ZipWriter::add, 3))?;
328
+ zip_writer.define_method("comment=", method!(archive::ZipWriter::set_comment, 1))?;
329
+ zip_writer.define_method("finish", method!(archive::ZipWriter::finish, 0))?;
330
+ let zip_reader = native.define_class("ZipReader", ruby.class_object())?;
331
+ zip_reader.define_singleton_method("new", function!(archive::ZipReader::new, 1))?;
332
+ zip_reader.define_method("names", method!(archive::ZipReader::names, 0))?;
333
+ zip_reader.define_method("entries", method!(archive::ZipReader::entries, 0))?;
334
+ zip_reader.define_method("read", method!(archive::ZipReader::read, 2))?;
335
+ zip_reader.define_method("extract", method!(archive::ZipReader::extract, 3))?;
287
336
  document::define(ruby, native)?;
288
337
  let workbook = native.define_class("Workbook", ruby.class_object())?;
289
338
  workbook.define_singleton_method("new", function!(NativeWorkbook::new, 0))?;
@@ -293,6 +342,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
293
342
  workbook.define_method("set_column_width", method!(NativeWorkbook::set_column_width, 3))?;
294
343
  workbook.define_method("add_validation", method!(NativeWorkbook::add_validation, 3))?;
295
344
  workbook.define_method("set_comment", method!(NativeWorkbook::set_comment, 4))?;
345
+ workbook.define_method("configure", method!(NativeWorkbook::configure, 3))?;
346
+ workbook.define_method("set_autofit", method!(NativeWorkbook::set_autofit, 2))?;
296
347
  workbook.define_method("set_right_to_left", method!(NativeWorkbook::set_right_to_left, 2))?;
297
348
  workbook.define_method("next_row", method!(NativeWorkbook::next_row, 1))?;
298
349
  workbook.define_method("render", method!(NativeWorkbook::render, 0))?;
@@ -13,13 +13,11 @@ use imprimatur::read::{Limits, content, images, json, markdown};
13
13
  use magnus::{Error, IntoValue, RArray, RString, Ruby};
14
14
  use serde_json::Value as Json;
15
15
 
16
+ use crate::blocking::MAX_ATTEMPTS;
16
17
  use crate::{argument_error, domain_error};
17
18
 
18
- /// Reads interrupted without anything to raise are retried this many times.
19
- const MAX_ATTEMPTS: usize = 64;
20
-
21
19
  /// A read of a document's bytes within limits, run without the GVL.
22
- type Job<T> = fn(&[u8], &Limits) -> imprimatur::Result<T>;
20
+ pub type Job<T> = fn(&[u8], &Limits) -> imprimatur::Result<T>;
23
21
 
24
22
  /// What crosses into the thread without the GVL, and comes back.
25
23
  struct Read<T> {
@@ -43,18 +41,18 @@ pub fn markdown_native(
43
41
  }
44
42
 
45
43
  /// Called by `Imprimatur.content`: the structure as JSON, the schema the
46
- /// command line prints, serialized before the GVL is taken back.
44
+ /// command line prints, with each formatted cell's look when `styles`.
47
45
  pub fn content_native(
48
46
  ruby: &Ruby,
49
47
  document: RString,
50
48
  max_input: Option<usize>,
51
49
  max_inflated: Option<u64>,
52
50
  timeout: Option<f64>,
51
+ styles: bool,
53
52
  ) -> Result<RString, Error> {
54
- let text = without_gvl(ruby, document, (max_input, max_inflated, timeout), |bytes, limits| {
55
- content(bytes, limits).map(|content| Json::Object(json::content(&content)).to_string())
56
- })?;
57
- Ok(ruby.str_new(&text))
53
+ let read = without_gvl(ruby, document, (max_input, max_inflated, timeout), content)?;
54
+ let options = json::Options { styles };
55
+ Ok(ruby.str_new(&Json::Object(json::content_with(&read, options)).to_string()))
58
56
  }
59
57
 
60
58
  /// Called by `Imprimatur.images`: `[data, description]` pairs, the data
@@ -109,16 +107,13 @@ fn limits(
109
107
  }
110
108
 
111
109
  /// Runs `job` on the document's bytes on this thread, the GVL released.
112
- fn without_gvl<T>(
110
+ pub fn without_gvl<T>(
113
111
  ruby: &Ruby,
114
112
  document: RString,
115
113
  keywords: (Option<usize>, Option<u64>, Option<f64>),
116
114
  job: Job<T>,
117
115
  ) -> Result<T, Error> {
118
- // SAFETY: the bytes are copied out at once, while the GVL is held and
119
- // before any Ruby code can run and move or free the string.
120
- #[allow(unsafe_code)]
121
- let document = unsafe { document.as_slice() }.to_vec();
116
+ let document = crate::bytes(document);
122
117
  let cancel = Arc::new(AtomicBool::new(false));
123
118
  let limits = limits(ruby, &cancel, keywords)?;
124
119
  let mut read = Read { document, limits, job, result: None };
@@ -0,0 +1,114 @@
1
+ //! `Imprimatur::Native.sheets`: a workbook's sheets as rows of Ruby values,
2
+ //! built from the cells directly instead of a hash per cell.
3
+
4
+ use imprimatur::read::{Cell, Content, Grid, Sheet, Value, content, json};
5
+ use imprimatur::xlsx::DateTime;
6
+ use magnus::prelude::*;
7
+ use magnus::{Error, IntoValue, RArray, RClass, RString, Ruby, Value as RubyValue};
8
+
9
+ use crate::domain_error;
10
+ use crate::read::without_gvl;
11
+
12
+ /// The largest whole `f64` an Integer can come from exactly.
13
+ const MAX_EXACT_INTEGER: f64 = 9_007_199_254_740_992.0;
14
+
15
+ /// Called by `Imprimatur::Spreadsheet.open`: `[name, hidden, rows, details]`
16
+ /// per sheet. The rows run from the first to the last with a value, each an
17
+ /// Array of values by column, `nil` where a cell is empty; the details are
18
+ /// the sheet's comments, columns, validations and, when `styles`, styles,
19
+ /// as JSON.
20
+ pub fn sheets_native(
21
+ ruby: &Ruby,
22
+ document: RString,
23
+ max_input: Option<usize>,
24
+ max_inflated: Option<u64>,
25
+ timeout: Option<f64>,
26
+ styles: bool,
27
+ ) -> Result<RArray, Error> {
28
+ let read = without_gvl(ruby, document, (max_input, max_inflated, timeout), content)?;
29
+ let Some(sheets) = read.sheets() else {
30
+ let kind = if matches!(read, Content::Pdf(_)) { "a PDF" } else { "a Word document" };
31
+ let error = imprimatur::Error::UnsupportedDocument(format!("{kind} is no spreadsheet"));
32
+ return Err(domain_error(ruby, &error));
33
+ };
34
+ let dates = Dates::load(ruby)?;
35
+ let out = ruby.ary_new_capa(sheets.len());
36
+ for sheet in sheets {
37
+ let details = json::sheet_details(sheet, json::Options { styles });
38
+ let entry = [
39
+ ruby.str_new(&sheet.name).into_value_with(ruby),
40
+ sheet.hidden.into_value_with(ruby),
41
+ rows(ruby, sheet, &dates)?.into_value_with(ruby),
42
+ ruby.str_new(&serde_json::Value::Object(details).to_string()).into_value_with(ruby),
43
+ ];
44
+ out.push(ruby.ary_new_from_values(&entry))?;
45
+ }
46
+ Ok(out)
47
+ }
48
+
49
+ fn rows(ruby: &Ruby, sheet: &Sheet, dates: &Dates) -> Result<RArray, Error> {
50
+ let grid = &sheet.cells;
51
+ let count = grid.cells().last().map_or(0, |cell| cell.row + 1);
52
+ let rows = ruby.ary_new_capa(usize::try_from(count).unwrap_or_default());
53
+ let mut filled = grid.rows().peekable();
54
+ for row in 0..count {
55
+ let values = ruby.ary_new();
56
+ if let Some(cells) =
57
+ filled.next_if(|cells| cells.first().is_some_and(|cell| cell.row == row))
58
+ {
59
+ for cell in cells {
60
+ let column = isize::try_from(cell.column).unwrap_or(isize::MAX);
61
+ values.store(column, value(ruby, grid, cell, dates)?)?;
62
+ }
63
+ }
64
+ rows.push(values)?;
65
+ }
66
+ Ok(rows)
67
+ }
68
+
69
+ /// A cell's value as Ruby has it. A formula with no computed result is nil.
70
+ fn value(ruby: &Ruby, grid: &Grid, cell: &Cell, dates: &Dates) -> Result<RubyValue, Error> {
71
+ let text = grid.text(cell);
72
+ Ok(match cell.value {
73
+ Value::Number(number) => number_value(ruby, number, text),
74
+ Value::Bool(on) => on.into_value_with(ruby),
75
+ Value::Date(date) => dates.value(date)?,
76
+ Value::Formula => ruby.qnil().as_value(),
77
+ _ => ruby.str_new(text).as_value(),
78
+ })
79
+ }
80
+
81
+ /// A whole number shown without a decimal point or a percent sign is an
82
+ /// Integer (`2`, `1,250`); any other a Float (`2.00`, `50%` as 0.5).
83
+ fn number_value(ruby: &Ruby, number: f64, shown: &str) -> RubyValue {
84
+ let whole = number.fract() == 0.0 && number.abs() <= MAX_EXACT_INTEGER;
85
+ if whole && !shown.contains(['.', '%']) {
86
+ #[expect(clippy::cast_possible_truncation, reason = "a whole number within 2**53")]
87
+ ruby.integer_from_i64(number as i64).as_value()
88
+ } else {
89
+ ruby.float_from_f64(number).as_value()
90
+ }
91
+ }
92
+
93
+ /// `Date` and `DateTime`, looked up once per call.
94
+ struct Dates {
95
+ date: RClass,
96
+ date_time: RClass,
97
+ }
98
+
99
+ impl Dates {
100
+ fn load(ruby: &Ruby) -> Result<Self, Error> {
101
+ let object = ruby.class_object();
102
+ Ok(Self { date: object.const_get("Date")?, date_time: object.const_get("DateTime")? })
103
+ }
104
+
105
+ /// A `Date`, or a `DateTime` when there is a time of day.
106
+ fn value(&self, date: DateTime) -> Result<RubyValue, Error> {
107
+ let (year, month, day) = date.year_month_day();
108
+ if !date.has_time() {
109
+ return self.date.funcall("new", (year, month, day));
110
+ }
111
+ let (hour, minute, second) = date.time_of_day();
112
+ self.date_time.funcall("new", (year, month, day, hour, minute, second))
113
+ }
114
+ }
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imprimatur
4
- # A flowing document (headings, paragraphs, tables, page breaks) that
5
- # renders to PDF and DOCX from the same content.
4
+ # A flowing document (headings, paragraphs, tables, pictures, page breaks)
5
+ # that renders to PDF and DOCX from the same content.
6
6
  #
7
7
  # doc = Imprimatur::Document.new(page_numbers: true)
8
8
  # doc.heading 'Quarterly report'
@@ -18,12 +18,35 @@ module Imprimatur
18
18
  # PDF uses the standard Helvetica fonts for now: Windows-1252 (Latin) text
19
19
  # only, anything else raises {Imprimatur::Error}. DOCX carries any script.
20
20
  class Document
21
- # @param page_size [:a4, :letter]
21
+ # @param page_size [:a4, :letter, Array(Numeric, Numeric)] or its width
22
+ # and height in points, 36 to 1584 a side
23
+ # @param margin [Numeric, nil] points on every side, one inch (72) unless set
22
24
  # @param page_numbers [Boolean] number pages "1 / N" in the footer
23
25
  # @raise [ArgumentError] an unknown page size
24
- def initialize(page_size: :a4, page_numbers: false)
26
+ # @raise [Imprimatur::Error] a page size or margin out of range
27
+ def initialize(page_size: :a4, margin: nil, page_numbers: false)
25
28
  @native = Native::Document.new
26
- @native.set_page(page_size.to_s, page_numbers ? true : false)
29
+ numbers = page_numbers ? true : false
30
+ if page_size.is_a?(Array)
31
+ width, height = page_size
32
+ @native.set_page_points(Float(width), Float(height), numbers)
33
+ else
34
+ @native.set_page(page_size.to_s, numbers)
35
+ end
36
+ @native.set_margin(Float(margin)) if margin
37
+ end
38
+
39
+ # A PNG or JPEG in the flow, at +width+ or +height+ points (the other
40
+ # follows its proportions), or else at 96 pixels an inch; never larger
41
+ # than the page leaves room for.
42
+ #
43
+ # @param source [String, Pathname, #read] a path, the bytes or an IO
44
+ # @param align [:left, :center, :right, nil]
45
+ # @return [self]
46
+ # @raise [Imprimatur::Error] no PNG or JPEG, or one it cannot draw
47
+ def image(source, width: nil, height: nil, align: nil)
48
+ @native.picture(Reader.read(source), width && Float(width), height && Float(height), align&.to_s)
49
+ self
27
50
  end
28
51
 
29
52
  # @param text [String]
@@ -40,7 +63,8 @@ module Imprimatur
40
63
  #
41
64
  # @param text [String, nil] plain text, styled by the keywords below
42
65
  # @param align [:left, :center, :right, nil]
43
- # @param direction [:ltr, :rtl, nil] right to left aligns right unless told otherwise
66
+ # @param direction [:ltr, :rtl, nil] nil takes the direction of the first letter;
67
+ # right to left aligns right unless told otherwise
44
68
  # @yieldparam paragraph [Paragraph] add runs with {Paragraph#text}
45
69
  # @return [self]
46
70
  def paragraph(text = nil, align: nil, direction: nil, **look)
@@ -9,11 +9,12 @@ module Imprimatur
9
9
  # A PDF without a text layer: its pages are images (scans), which need OCR.
10
10
  class NoTextLayer < Error; end
11
11
 
12
- # A PDF encrypted with a user password; only an empty one is tried.
12
+ # A PDF encrypted with a user password (only an empty one is tried), or
13
+ # an encrypted legacy +.xls+.
13
14
  class PasswordRequired < Error; end
14
15
 
15
- # A format {Imprimatur.markdown} does not read: legacy +.xls+ and +.doc+,
16
- # binary +.xlsb+, presentations, anything not a PDF, XLSX or DOCX.
16
+ # A format {Imprimatur.markdown} does not read: legacy +.doc+, binary
17
+ # +.xlsb+, presentations, anything not a PDF, XLSX, XLS, ODS or DOCX.
17
18
  class UnsupportedDocument < Error; end
18
19
 
19
20
  # The read hit one of its limits (size, decompressed size, nesting, time),
@@ -14,15 +14,15 @@ module Imprimatur
14
14
  module Reader
15
15
  # File signatures a document's bytes start with; a String that starts
16
16
  # with neither is taken for a path.
17
- SIGNATURES = ["%PDF-".b, "PK\x03\x04".b, "PK\x05\x06".b].freeze
17
+ SIGNATURES = ["%PDF-".b, "PK\x03\x04".b, "PK\x05\x06".b, "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b].freeze
18
18
  private_constant :SIGNATURES
19
19
 
20
20
  # @see Imprimatur.markdown
21
21
  def self.markdown(document, **) = Native.markdown(read(document), *limits(**))
22
22
 
23
23
  # @see Imprimatur.content
24
- def self.content(document, **)
25
- JSON.parse(Native.content(read(document), *limits(**)), symbolize_names: true)
24
+ def self.content(document, styles: false, **)
25
+ JSON.parse(Native.content(read(document), *limits(**), styles ? true : false), symbolize_names: true)
26
26
  end
27
27
 
28
28
  # @see Imprimatur.images
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'json'
5
+
6
+ module Imprimatur
7
+ # No row among the first ones of a sheet holds every header
8
+ # {Spreadsheet::Sheet#parse} looks for.
9
+ class HeaderRowNotFound < Error
10
+ # @return [Array<String>] the headers missing from the row that holds the most of them
11
+ attr_reader :missing
12
+
13
+ def initialize(missing)
14
+ @missing = missing.freeze
15
+ super("no header row has #{missing.join(', ')}")
16
+ end
17
+ end
18
+
19
+ # A workbook read for its values: +.xlsx+, +.xls+ (Excel 97-2003, and
20
+ # Excel 5.0/95) or +.ods+. Values come as Ruby has them: Strings,
21
+ # Integers for whole numbers shown without a decimal point or a percent
22
+ # sign, Floats, true and false, Dates, and DateTimes for a time of day;
23
+ # a formula without a computed result is nil.
24
+ #
25
+ # book = Imprimatur::Spreadsheet.open('suppliers.xlsx')
26
+ # book.names # => ["Suppliers", "Countries"]
27
+ # book.sheet.rows.first # => ["Name", "Country", "Since"]
28
+ # book.sheet.parse(name: 'Name', since: 'Since')
29
+ # # => [{ name: "Acme", since: #<Date: 2024-03-15> }, ...]
30
+ class Spreadsheet
31
+ # One sheet's values.
32
+ class Sheet
33
+ # How many rows from the top {#parse} searches for the header row.
34
+ HEADER_ROWS = 100
35
+
36
+ # @return [String]
37
+ attr_reader :name
38
+
39
+ # @return [Array<Array>] every row from the first to the last with a
40
+ # value, each an Array of values by column, nil where a cell is
41
+ # empty; a row without a value is an empty Array
42
+ attr_reader :rows
43
+
44
+ # @api private
45
+ def initialize(name, hidden, rows, details)
46
+ @name = name
47
+ @hidden = hidden
48
+ @rows = rows
49
+ @details = details
50
+ end
51
+
52
+ def hidden? = @hidden
53
+
54
+ # @return [Array<Hash>] +{cell:, author:, text:}+
55
+ def comments = details[:comments]
56
+
57
+ # @return [Array<Hash>] runs of +{first:, last:, width:, hidden:}+ columns, by letter
58
+ def columns = details[:columns]
59
+
60
+ # @return [Array<Hash>] +{range:, type:, formula1:, ...}+, as {Imprimatur.content} gives them
61
+ def validations = details[:validations]
62
+
63
+ # @return [Array<Hash>] each formatted cell's +{cell:, font:, size:, bold:, fill:, ...}+;
64
+ # empty unless the workbook was opened with +styles: true+
65
+ def styles = details.fetch(:styles, [])
66
+
67
+ # @return [Hash, nil] the validation over exactly +range+ (+"G2:G1000"+)
68
+ def validation(range) = validations.find { it[:range] == range }
69
+
70
+ # @return [Hash, nil] the look of the cell at +reference+ (+"A1"+)
71
+ def style(reference) = styles.find { it[:cell] == reference }
72
+
73
+ # The rows under the header row, as Hashes of +headers+' keys to the
74
+ # values in the columns their headers name. The header row is the
75
+ # first, among the first +header_rows+, to hold every header.
76
+ #
77
+ # sheet.parse(clean: true, name: 'Company Name', country: 'Country')
78
+ # # => [{ name: "Acme", country: "PL" }, ...]
79
+ #
80
+ # @param clean [Boolean] strips control characters, and whitespace at
81
+ # both ends, from every String, headers included
82
+ # @param header_rows [Integer]
83
+ # @param headers [Hash{Symbol => String}]
84
+ # @return [Array<Hash>]
85
+ # @raise [HeaderRowNotFound] no row holds every header
86
+ def parse(clean: false, header_rows: HEADER_ROWS, **headers)
87
+ rows = clean ? @rows.map { |row| row.map { clean(it) } } : @rows
88
+ header, columns = header_row(rows.first(header_rows), headers.values)
89
+ rows.drop(header + 1).map do |row|
90
+ headers.keys.zip(columns).to_h { |key, column| [key, row[column]] }
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ # The index of the first row holding every header, and their columns.
97
+ def header_row(rows, headers)
98
+ closest = []
99
+ rows.each_with_index do |row, index|
100
+ found = headers.filter_map { row.index(it) }
101
+ return [index, found] if found.size == headers.size
102
+
103
+ closest = headers.select { row.include?(it) } if found.size > closest.size
104
+ end
105
+ raise HeaderRowNotFound, headers - closest
106
+ end
107
+
108
+ def clean(value)
109
+ value.is_a?(String) ? value.gsub(/[[:cntrl:]]|\A[[:space:]]+|[[:space:]]+\z/, '') : value
110
+ end
111
+
112
+ def details
113
+ @details = JSON.parse(@details, symbolize_names: true) if @details.is_a?(String)
114
+ @details
115
+ end
116
+ end
117
+
118
+ # @param document [String, Pathname, #read] a path, the bytes or an IO
119
+ # @param styles [Boolean] each sheet's {Sheet#styles} too
120
+ # @param limits [Hash] +max_bytes:+, +max_inflated:+ and +timeout:+, as
121
+ # {Imprimatur.content} takes them
122
+ # @return [Spreadsheet]
123
+ # @raise [UnsupportedDocument] a PDF, a Word document or another format
124
+ # @raise [Imprimatur::Error] a damaged workbook, or past a limit
125
+ def self.open(document, styles: false, **limits)
126
+ new(Native.sheets(Reader.read(document), *Reader.limits(**limits), styles ? true : false))
127
+ end
128
+
129
+ # @return [Array<Sheet>] in the workbook's order, hidden ones included
130
+ attr_reader :sheets
131
+
132
+ # @api private
133
+ def initialize(sheets)
134
+ @sheets = sheets.map { |name, hidden, rows, details| Sheet.new(name, hidden, rows, details) }.freeze
135
+ end
136
+
137
+ # @return [Array<String>]
138
+ def names = sheets.map(&:name)
139
+
140
+ # The sheet named +name+, or the first one.
141
+ #
142
+ # @return [Sheet]
143
+ # @raise [KeyError] the workbook has no sheet of that name
144
+ def sheet(name = nil)
145
+ return sheets.first unless name
146
+
147
+ sheets.find { it.name == name } or
148
+ raise KeyError.new("no sheet named #{name.inspect}, only #{names.inspect}", receiver: self, key: name)
149
+ end
150
+ end
151
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imprimatur
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -22,10 +22,11 @@ module Imprimatur
22
22
  # Builds a workbook. With a path, saves it there and returns the path;
23
23
  # without, returns the +.xlsx+ bytes as a binary String.
24
24
  #
25
+ # @param options [Hash] +font:+, +size:+ and +autofit:+, as {Workbook#initialize} takes them
25
26
  # @yieldparam book [Workbook]
26
27
  # @raise [Imprimatur::Error] the workbook has no sheets, or the file cannot be written
27
- def self.build(path = nil)
28
- book = Workbook.new
28
+ def self.build(path = nil, **options)
29
+ book = Workbook.new(**options)
29
30
  yield book
30
31
  return book.render unless path
31
32
 
@@ -35,8 +36,15 @@ module Imprimatur
35
36
 
36
37
  # An ordered set of uniquely named sheets.
37
38
  class Workbook
38
- def initialize
39
+ # @param font [String, nil] the font of unstyled cells and of styles that
40
+ # name none; Calibri without one
41
+ # @param size [Numeric, nil] its size in points; 11 without one
42
+ # @param autofit [Boolean] every sheet sizes its columns to its cells, as
43
+ # {Sheet#autofit!} does
44
+ # @raise [Imprimatur::Error] a font name or size Excel rejects
45
+ def initialize(font: nil, size: nil, autofit: false)
39
46
  @native = Native::Workbook.new
47
+ @native.configure(font&.to_s, size && Float(size), autofit ? true : false)
40
48
  @sheets = []
41
49
  end
42
50
 
@@ -166,9 +174,22 @@ module Imprimatur
166
174
  self
167
175
  end
168
176
 
177
+ # Sizes the columns without a width of their own to their widest cell
178
+ # when the workbook is written, estimated as +(characters + 3) x points
179
+ # / 10+, bold cells one and a half times wider, at most 255.
180
+ # Formulas are left out.
181
+ #
182
+ # @return [self]
183
+ def autofit!(enabled = true)
184
+ @native.set_autofit(@index, enabled ? true : false)
185
+ self
186
+ end
187
+
169
188
  # Lays the sheet out right to left, column A on the right, as Excel
170
189
  # shows sheets in right-to-left languages: Hebrew, Arabic, Persian,
171
- # Urdu and the rest.
190
+ # Urdu and the rest; +false+ keeps it left to right. Without a call, a
191
+ # sheet is right to left when most of the letters of its name and text
192
+ # cells are.
172
193
  #
173
194
  # @return [self]
174
195
  def right_to_left!(enabled = true)