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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/Cargo.lock +123 -3
- data/Cargo.toml +1 -1
- data/README.md +98 -20
- data/ext/imprimatur_native/cli/Cargo.toml +1 -1
- data/ext/imprimatur_native/core/Cargo.toml +6 -1
- data/ext/imprimatur_native/core/README.md +12 -3
- data/ext/imprimatur_native/core/src/archive.rs +3 -16
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +10 -3
- data/ext/imprimatur_native/core/src/doc/picture.rs +192 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +21 -0
- data/ext/imprimatur_native/core/src/doc.rs +86 -2
- data/ext/imprimatur_native/core/src/docx/picture.rs +72 -0
- data/ext/imprimatur_native/core/src/docx.rs +91 -28
- data/ext/imprimatur_native/core/src/error.rs +12 -0
- data/ext/imprimatur_native/core/src/font.rs +229 -0
- data/ext/imprimatur_native/core/src/lib.rs +3 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +38 -15
- data/ext/imprimatur_native/core/src/pdf/objects.rs +29 -5
- data/ext/imprimatur_native/core/src/pdf/picture.rs +197 -0
- data/ext/imprimatur_native/core/src/pdf.rs +31 -3
- data/ext/imprimatur_native/core/src/read/content.rs +75 -2
- data/ext/imprimatur_native/core/src/read/json.rs +94 -7
- data/ext/imprimatur_native/core/src/read/numbers.rs +65 -42
- data/ext/imprimatur_native/core/src/read/ods.rs +1 -2
- data/ext/imprimatur_native/core/src/read/pdf/layout/bidi.rs +212 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +25 -119
- data/ext/imprimatur_native/core/src/read/xls.rs +1 -1
- data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -19
- data/ext/imprimatur_native/core/src/read.rs +4 -1
- data/ext/imprimatur_native/core/src/script.rs +116 -0
- data/ext/imprimatur_native/core/src/style.rs +21 -0
- data/ext/imprimatur_native/core/src/test_support.rs +3 -2
- data/ext/imprimatur_native/core/src/xlsx/autofit.rs +60 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +7 -1
- data/ext/imprimatur_native/core/src/xlsx/format.rs +22 -6
- data/ext/imprimatur_native/core/src/xlsx/package.rs +22 -8
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +9 -8
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +35 -7
- data/ext/imprimatur_native/core/src/xlsx.rs +40 -3
- data/ext/imprimatur_native/core/src/xml/reader.rs +10 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +3 -2
- data/ext/imprimatur_native/ffi/src/archive.rs +1 -3
- data/ext/imprimatur_native/ffi/src/document.rs +48 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +38 -1
- data/ext/imprimatur_native/ffi/src/read.rs +8 -11
- data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
- data/lib/imprimatur/document.rb +30 -6
- data/lib/imprimatur/reader.rb +2 -2
- data/lib/imprimatur/spreadsheet.rb +151 -0
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur/xlsx.rb +25 -4
- data/lib/imprimatur.rb +15 -3
- metadata +11 -1
|
@@ -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))?;
|
|
@@ -8,6 +8,7 @@ mod blocking;
|
|
|
8
8
|
mod convert;
|
|
9
9
|
mod document;
|
|
10
10
|
mod read;
|
|
11
|
+
mod sheets;
|
|
11
12
|
mod style;
|
|
12
13
|
mod validation;
|
|
13
14
|
|
|
@@ -131,6 +132,31 @@ impl NativeWorkbook {
|
|
|
131
132
|
Ok(())
|
|
132
133
|
}
|
|
133
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
|
+
|
|
134
160
|
fn set_right_to_left(
|
|
135
161
|
ruby: &Ruby,
|
|
136
162
|
rb_self: &Self,
|
|
@@ -178,6 +204,14 @@ impl std::io::Write for RubyBytes {
|
|
|
178
204
|
|
|
179
205
|
/// Mutable access to a wrapped value; Ruby code that calls back into the
|
|
180
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
|
+
|
|
181
215
|
fn borrow<'a, T>(ruby: &Ruby, cell: &'a RefCell<T>, what: &str) -> Result<RefMut<'a, T>, Error> {
|
|
182
216
|
cell.try_borrow_mut().map_err(|_| {
|
|
183
217
|
Error::new(
|
|
@@ -284,8 +318,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
284
318
|
|
|
285
319
|
let native = imprimatur.define_module("Native")?;
|
|
286
320
|
native.define_singleton_method("markdown", function!(read::markdown_native, 4))?;
|
|
287
|
-
native.define_singleton_method("content", function!(read::content_native,
|
|
321
|
+
native.define_singleton_method("content", function!(read::content_native, 5))?;
|
|
288
322
|
native.define_singleton_method("images", function!(read::images_native, 4))?;
|
|
323
|
+
native.define_singleton_method("sheets", function!(sheets::sheets_native, 5))?;
|
|
289
324
|
let zip_writer = native.define_class("ZipWriter", ruby.class_object())?;
|
|
290
325
|
zip_writer.define_singleton_method("new", function!(archive::ZipWriter::new, 1))?;
|
|
291
326
|
zip_writer.define_method("add_file", method!(archive::ZipWriter::add_file, 3))?;
|
|
@@ -307,6 +342,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
307
342
|
workbook.define_method("set_column_width", method!(NativeWorkbook::set_column_width, 3))?;
|
|
308
343
|
workbook.define_method("add_validation", method!(NativeWorkbook::add_validation, 3))?;
|
|
309
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))?;
|
|
310
347
|
workbook.define_method("set_right_to_left", method!(NativeWorkbook::set_right_to_left, 2))?;
|
|
311
348
|
workbook.define_method("next_row", method!(NativeWorkbook::next_row, 1))?;
|
|
312
349
|
workbook.define_method("render", method!(NativeWorkbook::render, 0))?;
|
|
@@ -17,7 +17,7 @@ use crate::blocking::MAX_ATTEMPTS;
|
|
|
17
17
|
use crate::{argument_error, domain_error};
|
|
18
18
|
|
|
19
19
|
/// A read of a document's bytes within limits, run without the GVL.
|
|
20
|
-
type Job<T> = fn(&[u8], &Limits) -> imprimatur::Result<T>;
|
|
20
|
+
pub type Job<T> = fn(&[u8], &Limits) -> imprimatur::Result<T>;
|
|
21
21
|
|
|
22
22
|
/// What crosses into the thread without the GVL, and comes back.
|
|
23
23
|
struct Read<T> {
|
|
@@ -41,18 +41,18 @@ pub fn markdown_native(
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/// Called by `Imprimatur.content`: the structure as JSON, the schema the
|
|
44
|
-
/// command line prints,
|
|
44
|
+
/// command line prints, with each formatted cell's look when `styles`.
|
|
45
45
|
pub fn content_native(
|
|
46
46
|
ruby: &Ruby,
|
|
47
47
|
document: RString,
|
|
48
48
|
max_input: Option<usize>,
|
|
49
49
|
max_inflated: Option<u64>,
|
|
50
50
|
timeout: Option<f64>,
|
|
51
|
+
styles: bool,
|
|
51
52
|
) -> Result<RString, Error> {
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
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()))
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/// Called by `Imprimatur.images`: `[data, description]` pairs, the data
|
|
@@ -107,16 +107,13 @@ fn limits(
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/// Runs `job` on the document's bytes on this thread, the GVL released.
|
|
110
|
-
fn without_gvl<T>(
|
|
110
|
+
pub fn without_gvl<T>(
|
|
111
111
|
ruby: &Ruby,
|
|
112
112
|
document: RString,
|
|
113
113
|
keywords: (Option<usize>, Option<u64>, Option<f64>),
|
|
114
114
|
job: Job<T>,
|
|
115
115
|
) -> Result<T, Error> {
|
|
116
|
-
|
|
117
|
-
// before any Ruby code can run and move or free the string.
|
|
118
|
-
#[allow(unsafe_code)]
|
|
119
|
-
let document = unsafe { document.as_slice() }.to_vec();
|
|
116
|
+
let document = crate::bytes(document);
|
|
120
117
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
121
118
|
let limits = limits(ruby, &cancel, keywords)?;
|
|
122
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
|
+
}
|
data/lib/imprimatur/document.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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)
|
data/lib/imprimatur/reader.rb
CHANGED
|
@@ -21,8 +21,8 @@ module Imprimatur
|
|
|
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
|
data/lib/imprimatur/version.rb
CHANGED
data/lib/imprimatur/xlsx.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/imprimatur.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'imprimatur/validation'
|
|
|
9
9
|
require_relative 'imprimatur/xlsx'
|
|
10
10
|
require_relative 'imprimatur/document'
|
|
11
11
|
require_relative 'imprimatur/reader'
|
|
12
|
+
require_relative 'imprimatur/spreadsheet'
|
|
12
13
|
require_relative 'imprimatur/zip'
|
|
13
14
|
|
|
14
15
|
# Document authoring with a native core: XLSX workbooks, and PDF and DOCX from
|
|
@@ -25,7 +26,7 @@ require_relative 'imprimatur/zip'
|
|
|
25
26
|
module Imprimatur
|
|
26
27
|
class << self
|
|
27
28
|
# @see Xlsx.build
|
|
28
|
-
def xlsx(path = nil, &) = Xlsx.build(path, &)
|
|
29
|
+
def xlsx(path = nil, **, &) = Xlsx.build(path, **, &)
|
|
29
30
|
|
|
30
31
|
# @param expression [String] such as +"SUM(A1:A3)"+ or +"=A1*2"+
|
|
31
32
|
# @return [Formula]
|
|
@@ -78,18 +79,29 @@ module Imprimatur
|
|
|
78
79
|
# (+heading+, +paragraph+) with their +:bounds+ in points from the
|
|
79
80
|
# page's top-left corner; a workbook's +:sheets+ of +:rows+ of typed
|
|
80
81
|
# +:cells+ (+number+, +date+, +bool+, +text+, +error+, +formula+) and
|
|
81
|
-
# +:comments
|
|
82
|
+
# +:comments+, and from an +.xlsx+ its +:columns+ widths and data
|
|
83
|
+
# +:validations+; a Word document's +:blocks+ (+heading+, +paragraph+,
|
|
82
84
|
# +list_item+, +table+), their bold, italic and linked +:marks+ as byte
|
|
83
85
|
# ranges. Dates are ISO 8601 strings: Excel's have no time zone.
|
|
84
86
|
#
|
|
85
87
|
# sheet = Imprimatur.content('sales.xlsx')[:sheets].first
|
|
86
88
|
# sheet[:rows][1][:cells] # => [{cell: "A2", type: "text", value: "North"}, ...]
|
|
87
89
|
#
|
|
88
|
-
# Takes the arguments of {.markdown}, and raises as it does.
|
|
90
|
+
# Takes the arguments of {.markdown}, and raises as it does. With
|
|
91
|
+
# +styles: true+ each sheet also lists its formatted cells' +:styles+:
|
|
92
|
+
# font, size, bold, italic, underline, ARGB color and fill, alignment,
|
|
93
|
+
# wrap, border and number format.
|
|
89
94
|
#
|
|
90
95
|
# @return [Hash] with symbol keys; +:format+ is +"pdf"+, +"xlsx"+ or +"docx"+
|
|
91
96
|
def content(document, ...) = Reader.content(document, ...)
|
|
92
97
|
|
|
98
|
+
# A workbook's sheets and their values, read from the same documents
|
|
99
|
+
# {.markdown} takes (+.xlsx+, +.xls+, +.ods+); see {Spreadsheet}.
|
|
100
|
+
#
|
|
101
|
+
# @return [Spreadsheet]
|
|
102
|
+
# @raise [UnsupportedDocument] a PDF, a Word document or another format
|
|
103
|
+
def spreadsheet(document, ...) = Spreadsheet.open(document, ...)
|
|
104
|
+
|
|
93
105
|
# A document's images, as files: a PDF's, once each, from the first page
|
|
94
106
|
# that draws them (JPEG and JPEG 2000 as stored, fax scans as TIFF, raw
|
|
95
107
|
# pixels as PNG); a Word or Excel document's media. A scanned PDF gives
|