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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//! Scripts and the direction they are written in, for reading text back
|
|
2
|
+
//! in order and for writing it with the right direction.
|
|
3
|
+
|
|
4
|
+
#[cfg(test)]
|
|
5
|
+
mod tests;
|
|
6
|
+
|
|
7
|
+
use crate::style::TextDirection;
|
|
8
|
+
|
|
9
|
+
/// What a character is, for the direction of the text around it (its
|
|
10
|
+
/// UAX #9 bidirectional class, simplified).
|
|
11
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
12
|
+
pub enum Class {
|
|
13
|
+
/// A letter of Hebrew or another right-to-left script but Arabic's, or
|
|
14
|
+
/// the right-to-left mark.
|
|
15
|
+
Right,
|
|
16
|
+
/// A letter of the Arabic script (Arabic, Persian, Urdu..., Syriac,
|
|
17
|
+
/// Thaana): right to left, and the digits after it are Arabic numbers.
|
|
18
|
+
Arabic,
|
|
19
|
+
/// Any other letter, or the left-to-right mark.
|
|
20
|
+
Left,
|
|
21
|
+
/// A number: its digits read left to right in any line, but in a
|
|
22
|
+
/// right-to-left run the spaces between two numbers read right to left.
|
|
23
|
+
Number,
|
|
24
|
+
/// Arabic-Indic digits (not the Persian ones), and digits after Arabic
|
|
25
|
+
/// letters: a number that only `,` `.` `:` `/` join, and no sign goes
|
|
26
|
+
/// with (UAX #9, W2).
|
|
27
|
+
ArabicNumber,
|
|
28
|
+
/// Spaces, punctuation and symbols: they take the direction around them.
|
|
29
|
+
Neutral,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl Class {
|
|
33
|
+
pub const fn right_to_left(self) -> bool {
|
|
34
|
+
matches!(self, Self::Right | Self::Arabic)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub const fn number(self) -> bool {
|
|
38
|
+
matches!(self, Self::Number | Self::ArabicNumber)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub const fn strong(self) -> bool {
|
|
42
|
+
matches!(self, Self::Right | Self::Arabic | Self::Left)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// The letters and digits of some text, by direction.
|
|
47
|
+
#[derive(Debug, Default, Clone, Copy)]
|
|
48
|
+
pub struct Counts {
|
|
49
|
+
pub right: usize,
|
|
50
|
+
pub left: usize,
|
|
51
|
+
pub numbers: usize,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
impl Counts {
|
|
55
|
+
pub fn add(&mut self, text: &str) {
|
|
56
|
+
for ch in text.chars() {
|
|
57
|
+
match class(ch) {
|
|
58
|
+
Class::Right | Class::Arabic => self.right += 1,
|
|
59
|
+
Class::Left if ch.is_alphabetic() => self.left += 1,
|
|
60
|
+
Class::Number | Class::ArabicNumber => self.numbers += 1,
|
|
61
|
+
Class::Left | Class::Neutral => {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Whether more of the letters read right to left than left to right.
|
|
67
|
+
pub const fn right_to_left(self) -> bool {
|
|
68
|
+
self.right > self.left
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The direction most of the letters read in; `None` without a letter.
|
|
72
|
+
pub const fn direction(self) -> Option<TextDirection> {
|
|
73
|
+
if self.right > self.left {
|
|
74
|
+
Some(TextDirection::RightToLeft)
|
|
75
|
+
} else if self.left > 0 {
|
|
76
|
+
Some(TextDirection::LeftToRight)
|
|
77
|
+
} else {
|
|
78
|
+
None
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// The direction of a character.
|
|
84
|
+
///
|
|
85
|
+
/// Right to left: Hebrew (and Yiddish, Ladino), Arabic (and Persian, Urdu,
|
|
86
|
+
/// Pashto, Sindhi, Kurdish, Uyghur), Syriac, Thaana (Dhivehi), N'Ko,
|
|
87
|
+
/// Samaritan, Mandaic, their presentation forms; Adlam (Fulani), Hanifi
|
|
88
|
+
/// Rohingya, Mende Kikakui, Yezidi, Garay and the historic scripts
|
|
89
|
+
/// (Phoenician, Aramaic, Sogdian...); and the right-to-left mark. Of
|
|
90
|
+
/// those, the Arabic script (with Syriac, Thaana, Hanifi Rohingya and the
|
|
91
|
+
/// Siyaq numbers) is set apart. Digits are numbers, the Persian ones
|
|
92
|
+
/// (Extended Arabic-Indic) too; Arabic-Indic ones are Arabic numbers. The
|
|
93
|
+
/// Arabic comma, percent sign and separators are neutral, like their Latin
|
|
94
|
+
/// counterparts.
|
|
95
|
+
pub fn class(ch: char) -> Class {
|
|
96
|
+
match ch {
|
|
97
|
+
'0'..='9' | '\u{06F0}'..='\u{06F9}' => Class::Number,
|
|
98
|
+
'\u{0660}'..='\u{0669}' => Class::ArabicNumber,
|
|
99
|
+
'\u{060C}' | '\u{066A}'..='\u{066C}' => Class::Neutral,
|
|
100
|
+
'\u{0600}'..='\u{07BF}'
|
|
101
|
+
| '\u{0860}'..='\u{08FF}'
|
|
102
|
+
| '\u{FB50}'..='\u{FDFF}'
|
|
103
|
+
| '\u{FE70}'..='\u{FEFC}'
|
|
104
|
+
| '\u{10D00}'..='\u{10D3F}'
|
|
105
|
+
| '\u{10EC0}'..='\u{10EFF}'
|
|
106
|
+
| '\u{1EC70}'..='\u{1EEFF}' => Class::Arabic,
|
|
107
|
+
'\u{0590}'..='\u{08FF}'
|
|
108
|
+
| '\u{200F}'
|
|
109
|
+
| '\u{FB1D}'..='\u{FB4F}'
|
|
110
|
+
| '\u{10800}'..='\u{10FFF}'
|
|
111
|
+
| '\u{1E800}'..='\u{1EFFF}' => Class::Right,
|
|
112
|
+
'\u{200E}' => Class::Left,
|
|
113
|
+
ch if ch.is_alphanumeric() => Class::Left,
|
|
114
|
+
_ => Class::Neutral,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
#[cfg(test)]
|
|
4
4
|
mod tests;
|
|
5
5
|
|
|
6
|
+
use crate::script::{Class, class};
|
|
6
7
|
use crate::{Error, Result};
|
|
7
8
|
|
|
8
9
|
/// An opaque RGB color.
|
|
@@ -72,6 +73,26 @@ pub enum TextDirection {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
impl TextDirection {
|
|
76
|
+
/// The direction text takes on its own, the way Word and browsers settle
|
|
77
|
+
/// a paragraph left to automatic: that of its first letter (UAX #9, P2).
|
|
78
|
+
/// `None` when it has no letter: digits, punctuation, nothing.
|
|
79
|
+
///
|
|
80
|
+
/// ```
|
|
81
|
+
/// use imprimatur::style::TextDirection;
|
|
82
|
+
///
|
|
83
|
+
/// assert_eq!(TextDirection::of("2024 شهادة"), Some(TextDirection::RightToLeft));
|
|
84
|
+
/// assert_eq!(TextDirection::of("ISO 9001 אישור"), Some(TextDirection::LeftToRight));
|
|
85
|
+
/// assert_eq!(TextDirection::of("1,250.50"), None);
|
|
86
|
+
/// ```
|
|
87
|
+
#[must_use]
|
|
88
|
+
pub fn of(text: &str) -> Option<Self> {
|
|
89
|
+
text.chars().find_map(|ch| match class(ch) {
|
|
90
|
+
Class::Right | Class::Arabic => Some(Self::RightToLeft),
|
|
91
|
+
Class::Left => Some(Self::LeftToRight),
|
|
92
|
+
Class::Number | Class::ArabicNumber | Class::Neutral => None,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/// `readingOrder`: 1 left to right, 2 right to left (0 is context).
|
|
76
97
|
pub(crate) const fn reading_order(self) -> &'static str {
|
|
77
98
|
match self {
|
|
@@ -16,11 +16,12 @@ pub fn read_zip(bytes: Vec<u8>) -> Vec<(String, Vec<u8>)> {
|
|
|
16
16
|
.collect()
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/// Every entry of a zip archive as text, by name, read as [`read_zip`] does
|
|
19
|
+
/// Every entry of a zip archive as text, by name, read as [`read_zip`] does;
|
|
20
|
+
/// media come out garbled, for their names.
|
|
20
21
|
pub fn text_parts(bytes: Vec<u8>) -> Vec<(String, String)> {
|
|
21
22
|
read_zip(bytes)
|
|
22
23
|
.into_iter()
|
|
23
|
-
.map(|(name, data)| (name, String::
|
|
24
|
+
.map(|(name, data)| (name, String::from_utf8_lossy(&data).into_owned()))
|
|
24
25
|
.collect()
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//! Column widths from the cells. There is no font to measure text with, so
|
|
2
|
+
//! a column is as wide as its widest cell's character count says, scaled by
|
|
3
|
+
//! that cell's font.
|
|
4
|
+
|
|
5
|
+
#[cfg(test)]
|
|
6
|
+
mod tests;
|
|
7
|
+
|
|
8
|
+
use std::collections::BTreeMap;
|
|
9
|
+
|
|
10
|
+
use super::cell::Cell;
|
|
11
|
+
use super::format::Font;
|
|
12
|
+
use super::worksheet::Worksheet;
|
|
13
|
+
|
|
14
|
+
/// Excel's widest column, in characters.
|
|
15
|
+
const MAX_WIDTH: f64 = 255.0;
|
|
16
|
+
|
|
17
|
+
/// The width each column without one of its own needs for its widest cell:
|
|
18
|
+
/// `(characters + 3) × points / 10`, a bold cell counting one and a half
|
|
19
|
+
/// times, at most 255. Formulas are left out: their results are unknown.
|
|
20
|
+
pub fn widths(sheet: &Worksheet, base: &Font) -> BTreeMap<u16, f64> {
|
|
21
|
+
let mut widths = BTreeMap::new();
|
|
22
|
+
for (_, cells) in sheet.rows() {
|
|
23
|
+
for (&column, entry) in cells {
|
|
24
|
+
if sheet.column_widths().contains_key(&column) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let Some(characters) = characters(&entry.cell) else {
|
|
28
|
+
continue;
|
|
29
|
+
};
|
|
30
|
+
let font = usize::try_from(entry.style)
|
|
31
|
+
.ok()
|
|
32
|
+
.and_then(|style| style.checked_sub(1))
|
|
33
|
+
.and_then(|index| sheet.formats().get(index))
|
|
34
|
+
.map_or(base, |format| &format.font);
|
|
35
|
+
let bold = if font.bold { 1.5 } else { 1.0 };
|
|
36
|
+
#[expect(
|
|
37
|
+
clippy::cast_precision_loss,
|
|
38
|
+
reason = "a cell holds at most 32 767 characters"
|
|
39
|
+
)]
|
|
40
|
+
let width = (characters as f64 + 3.0) * f64::from(font.half_points(base)) / 20.0 * bold;
|
|
41
|
+
let width = (width.min(MAX_WIDTH) * 100.0).round() / 100.0;
|
|
42
|
+
let widest = widths.entry(column).or_insert(0.0_f64);
|
|
43
|
+
*widest = widest.max(width);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
widths
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// How many characters a cell shows unformatted: a text's longest line,
|
|
50
|
+
/// a number's digits, `TRUE`, a date as `yyyy-mm-dd` and a time
|
|
51
|
+
/// `yyyy-mm-dd hh:mm:ss`.
|
|
52
|
+
fn characters(cell: &Cell) -> Option<usize> {
|
|
53
|
+
match cell {
|
|
54
|
+
Cell::Text(text) => text.split('\n').map(|line| line.chars().count()).max(),
|
|
55
|
+
Cell::Number(number) => Some(number.to_string().len()),
|
|
56
|
+
Cell::Bool(value) => Some(if *value { 4 } else { 5 }),
|
|
57
|
+
Cell::DateTime(date) => Some(if date.has_time() { 19 } else { 10 }),
|
|
58
|
+
Cell::Formula(_) => None,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -90,6 +90,12 @@ impl DateTime {
|
|
|
90
90
|
Self { hour: 0, minute: 0, second: 0, nanosecond: 0, ..self }
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/// Year, month and day.
|
|
94
|
+
#[must_use]
|
|
95
|
+
pub const fn year_month_day(&self) -> (u16, u8, u8) {
|
|
96
|
+
(self.year, self.month, self.day)
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
/// Hours, minutes and seconds.
|
|
94
100
|
#[must_use]
|
|
95
101
|
pub const fn time_of_day(&self) -> (u8, u8, u8) {
|
|
@@ -147,7 +153,7 @@ const fn days_in_month(year: u16, month: u8) -> u8 {
|
|
|
147
153
|
|
|
148
154
|
/// The calendar date `days` after 1970-01-01: the inverse of
|
|
149
155
|
/// [`days_from_civil`] (Howard Hinnant's `civil_from_days`).
|
|
150
|
-
const fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
|
156
|
+
pub const fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
|
151
157
|
let days = days + 719_468;
|
|
152
158
|
let era = days.div_euclid(146_097);
|
|
153
159
|
let day_of_era = days - era * 146_097;
|
|
@@ -132,7 +132,7 @@ impl Format {
|
|
|
132
132
|
if length == 0 || length > MAX_FONT_NAME || name.chars().any(char::is_control) {
|
|
133
133
|
return Err(Error::InvalidFontName(name));
|
|
134
134
|
}
|
|
135
|
-
self.font.name = (name
|
|
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
|
|
@@ -45,9 +45,7 @@ impl ZipWriter {
|
|
|
45
45
|
data: RString,
|
|
46
46
|
comment: Option<String>,
|
|
47
47
|
) -> Result<(), Error> {
|
|
48
|
-
|
|
49
|
-
#[allow(unsafe_code)]
|
|
50
|
-
let data = unsafe { data.as_slice() }.to_vec();
|
|
48
|
+
let data = crate::bytes(data);
|
|
51
49
|
let mut slot = borrow(ruby, &rb_self.0, "zip writer")?;
|
|
52
50
|
let writer = slot.as_mut().ok_or_else(|| finished(ruby))?;
|
|
53
51
|
without_gvl(ruby, || writer.add(&name, &data, comment.as_deref()))?.or_raise(ruby)
|