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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/Cargo.lock +124 -3
- data/Cargo.toml +1 -1
- data/README.md +123 -23
- data/ext/imprimatur_native/cli/Cargo.toml +2 -2
- data/ext/imprimatur_native/cli/src/cli/grep.rs +1 -1
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +2 -2
- data/ext/imprimatur_native/cli/src/cli.rs +5 -5
- data/ext/imprimatur_native/cli/src/main.rs +1 -1
- data/ext/imprimatur_native/core/Cargo.toml +12 -2
- data/ext/imprimatur_native/core/README.md +15 -1
- data/ext/imprimatur_native/core/src/archive.rs +279 -0
- 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 +13 -1
- data/ext/imprimatur_native/core/src/font.rs +229 -0
- data/ext/imprimatur_native/core/src/lib.rs +5 -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 +91 -3
- data/ext/imprimatur_native/core/src/read/json.rs +96 -7
- data/ext/imprimatur_native/core/src/read/markdown.rs +1 -1
- data/ext/imprimatur_native/core/src/read/{xlsx/numbers.rs → numbers.rs} +69 -45
- data/ext/imprimatur_native/core/src/read/ods.rs +356 -0
- data/ext/imprimatur_native/core/src/read/ole.rs +244 -0
- data/ext/imprimatur_native/core/src/read/package.rs +0 -5
- 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/strings.rs +156 -0
- data/ext/imprimatur_native/core/src/read/xls.rs +342 -0
- data/ext/imprimatur_native/core/src/read/xlsx/formats.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +104 -20
- data/ext/imprimatur_native/core/src/read.rs +68 -21
- 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 +90 -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 +119 -0
- data/ext/imprimatur_native/ffi/src/blocking.rs +63 -0
- data/ext/imprimatur_native/ffi/src/document.rs +48 -1
- data/ext/imprimatur_native/ffi/src/lib.rs +52 -1
- data/ext/imprimatur_native/ffi/src/read.rs +9 -14
- data/ext/imprimatur_native/ffi/src/sheets.rs +114 -0
- data/lib/imprimatur/document.rb +30 -6
- data/lib/imprimatur/error.rb +4 -3
- data/lib/imprimatur/reader.rb +3 -3
- data/lib/imprimatur/spreadsheet.rb +151 -0
- data/lib/imprimatur/version.rb +1 -1
- data/lib/imprimatur/xlsx.rb +25 -4
- data/lib/imprimatur/zip.rb +127 -0
- data/lib/imprimatur.rb +18 -5
- 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
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
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>(
|
|
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
|
-
|
|
220
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
178
|
-
///
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|