imprimatur 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Cargo.lock +835 -0
  4. data/Cargo.toml +44 -0
  5. data/LICENSE.txt +29 -0
  6. data/README.md +314 -0
  7. data/ext/imprimatur_native/cli/Cargo.toml +30 -0
  8. data/ext/imprimatur_native/cli/src/app.rs +183 -0
  9. data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
  10. data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
  11. data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
  12. data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
  13. data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
  14. data/ext/imprimatur_native/cli/src/cli.rs +71 -0
  15. data/ext/imprimatur_native/cli/src/main.rs +15 -0
  16. data/ext/imprimatur_native/core/Cargo.toml +33 -0
  17. data/ext/imprimatur_native/core/LICENSE +29 -0
  18. data/ext/imprimatur_native/core/README.md +105 -0
  19. data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
  20. data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
  21. data/ext/imprimatur_native/core/src/doc.rs +210 -0
  22. data/ext/imprimatur_native/core/src/docx.rs +343 -0
  23. data/ext/imprimatur_native/core/src/error.rs +113 -0
  24. data/ext/imprimatur_native/core/src/lib.rs +38 -0
  25. data/ext/imprimatur_native/core/src/opc.rs +51 -0
  26. data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
  27. data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
  28. data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
  29. data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
  30. data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
  31. data/ext/imprimatur_native/core/src/pdf.rs +84 -0
  32. data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
  33. data/ext/imprimatur_native/core/src/read/content.rs +266 -0
  34. data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
  35. data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
  36. data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
  37. data/ext/imprimatur_native/core/src/read/image.rs +233 -0
  38. data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
  39. data/ext/imprimatur_native/core/src/read/json.rs +172 -0
  40. data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
  41. data/ext/imprimatur_native/core/src/read/package.rs +191 -0
  42. data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
  43. data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
  44. data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
  45. data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
  46. data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
  47. data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
  48. data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
  49. data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
  50. data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
  51. data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
  52. data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
  53. data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
  54. data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
  55. data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
  56. data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
  57. data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
  58. data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
  59. data/ext/imprimatur_native/core/src/read/span.rs +22 -0
  60. data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
  61. data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
  62. data/ext/imprimatur_native/core/src/read.rs +145 -0
  63. data/ext/imprimatur_native/core/src/style.rs +82 -0
  64. data/ext/imprimatur_native/core/src/test_support.rs +163 -0
  65. data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
  66. data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
  67. data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
  68. data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
  69. data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
  70. data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
  71. data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
  72. data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
  73. data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
  74. data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
  75. data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
  76. data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
  77. data/ext/imprimatur_native/core/src/xml.rs +95 -0
  78. data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
  79. data/ext/imprimatur_native/core/src/zip.rs +161 -0
  80. data/ext/imprimatur_native/extconf.rb +45 -0
  81. data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
  82. data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
  83. data/ext/imprimatur_native/ffi/src/document.rs +138 -0
  84. data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
  85. data/ext/imprimatur_native/ffi/src/read.rs +182 -0
  86. data/ext/imprimatur_native/ffi/src/style.rs +85 -0
  87. data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
  88. data/lib/imprimatur/document.rb +127 -0
  89. data/lib/imprimatur/error.rb +22 -0
  90. data/lib/imprimatur/formula.rb +13 -0
  91. data/lib/imprimatur/native.rb +17 -0
  92. data/lib/imprimatur/reader.rb +59 -0
  93. data/lib/imprimatur/style.rb +31 -0
  94. data/lib/imprimatur/validation.rb +35 -0
  95. data/lib/imprimatur/version.rb +5 -0
  96. data/lib/imprimatur/xlsx.rb +183 -0
  97. data/lib/imprimatur.rb +111 -0
  98. metadata +157 -0
@@ -0,0 +1,173 @@
1
+ //! Dates as Excel stores them: a number of days since 1900-01-00, with the
2
+ //! time of day as the fraction. No time zone; the wall-clock fields are what
3
+ //! the cell shows.
4
+
5
+ #[cfg(test)]
6
+ mod tests;
7
+
8
+ use std::fmt;
9
+
10
+ use crate::{Error, Result};
11
+
12
+ const SECONDS_PER_DAY: f64 = 86_400.0;
13
+
14
+ /// A date, optionally with a time of day, between 1900-01-01 and 9999-12-31.
15
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16
+ pub struct DateTime {
17
+ year: u16,
18
+ month: u8,
19
+ day: u8,
20
+ hour: u8,
21
+ minute: u8,
22
+ second: u8,
23
+ nanosecond: u32,
24
+ }
25
+
26
+ impl DateTime {
27
+ /// Midnight on a calendar date.
28
+ ///
29
+ /// # Errors
30
+ /// [`Error::DateOutOfRange`] for an invalid date or one Excel cannot hold.
31
+ pub fn date(year: u16, month: u8, day: u8) -> Result<Self> {
32
+ let date = Self { year, month, day, hour: 0, minute: 0, second: 0, nanosecond: 0 };
33
+ let valid = (1900..=9999).contains(&year)
34
+ && (1..=12).contains(&month)
35
+ && day >= 1
36
+ && day <= days_in_month(year, month);
37
+ valid.ok_or_else(|| Error::DateOutOfRange(date.to_string()))?;
38
+ Ok(date)
39
+ }
40
+
41
+ /// The same date at a time of day. Excel shows milliseconds at most.
42
+ ///
43
+ /// # Errors
44
+ /// [`Error::DateOutOfRange`] for an hour, minute, second or nanosecond
45
+ /// out of range (leap seconds included).
46
+ pub fn at(self, hour: u8, minute: u8, second: u8, nanosecond: u32) -> Result<Self> {
47
+ let time = Self { hour, minute, second, nanosecond, ..self };
48
+ (hour < 24 && minute < 60 && second < 60 && nanosecond < 1_000_000_000)
49
+ .ok_or_else(|| Error::DateOutOfRange(time.to_string()))?;
50
+ Ok(time)
51
+ }
52
+
53
+ /// The date a serial number stands for, to the second: the inverse of
54
+ /// [`serial`](Self::serial). `date1904` is the Mac epoch some workbooks
55
+ /// use, where 0 is 1904-01-01. `None` outside 1900-01-01 to 9999-12-31,
56
+ /// and for 60, the 1900-02-29 that never existed.
57
+ #[must_use]
58
+ pub fn from_serial(serial: f64, date1904: bool) -> Option<Self> {
59
+ if !serial.is_finite() || serial < 0.0 {
60
+ return None;
61
+ }
62
+ #[expect(clippy::cast_possible_truncation, reason = "checked against 9999-12-31 below")]
63
+ let seconds = (serial * SECONDS_PER_DAY).round() as i64;
64
+ let (mut days, seconds) = (seconds.div_euclid(86_400), seconds.rem_euclid(86_400));
65
+ days += if date1904 {
66
+ days_from_civil(1904, 1, 1)
67
+ } else if days == 60 {
68
+ return None;
69
+ } else if days > 60 {
70
+ days_from_civil(1899, 12, 30)
71
+ } else {
72
+ days_from_civil(1899, 12, 31)
73
+ };
74
+ let (year, month, day) = civil_from_days(days);
75
+ let (Ok(year), Ok(month), Ok(day)) =
76
+ (u16::try_from(year), u8::try_from(month), u8::try_from(day))
77
+ else {
78
+ return None;
79
+ };
80
+ let (hour, minute, second) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
81
+ #[expect(clippy::cast_possible_truncation, reason = "a time of day: 0 to 59, or 23")]
82
+ #[expect(clippy::cast_sign_loss, reason = "rem_euclid is never negative")]
83
+ let time = (hour as u8, minute as u8, second as u8);
84
+ Self::date(year, month, day).ok()?.at(time.0, time.1, time.2, 0).ok()
85
+ }
86
+
87
+ /// The same day at midnight.
88
+ #[must_use]
89
+ pub const fn date_only(self) -> Self {
90
+ Self { hour: 0, minute: 0, second: 0, nanosecond: 0, ..self }
91
+ }
92
+
93
+ /// Hours, minutes and seconds.
94
+ #[must_use]
95
+ pub const fn time_of_day(&self) -> (u8, u8, u8) {
96
+ (self.hour, self.minute, self.second)
97
+ }
98
+
99
+ /// Whether the time of day is past midnight.
100
+ #[must_use]
101
+ pub const fn has_time(&self) -> bool {
102
+ self.hour != 0 || self.minute != 0 || self.second != 0 || self.nanosecond != 0
103
+ }
104
+
105
+ /// The Excel serial number: 1 is 1900-01-01, 45000 is 2023-03-15.
106
+ #[must_use]
107
+ pub fn serial(&self) -> f64 {
108
+ let (year, month, day) = (i64::from(self.year), i64::from(self.month), i64::from(self.day));
109
+ let mut days = days_from_civil(year, month, day) - days_from_civil(1899, 12, 31);
110
+ // Excel keeps Lotus 1-2-3's 1900-02-29, which never existed: every
111
+ // later date is one day further along.
112
+ if (self.year, self.month) >= (1900, 3) {
113
+ days += 1;
114
+ }
115
+ let seconds =
116
+ u32::from(self.hour) * 3600 + u32::from(self.minute) * 60 + u32::from(self.second);
117
+ let fraction = (f64::from(seconds) + f64::from(self.nanosecond) / 1e9) / SECONDS_PER_DAY;
118
+ #[expect(clippy::cast_precision_loss, reason = "at most 2958465 days, far below 2**53")]
119
+ let days = days as f64;
120
+ days + fraction
121
+ }
122
+ }
123
+
124
+ impl fmt::Display for DateTime {
125
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126
+ write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)?;
127
+ if self.has_time() {
128
+ write!(f, " {:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
129
+ if self.nanosecond != 0 {
130
+ write!(f, ".{:09}", self.nanosecond)?;
131
+ }
132
+ }
133
+ Ok(())
134
+ }
135
+ }
136
+
137
+ const fn days_in_month(year: u16, month: u8) -> u8 {
138
+ match month {
139
+ 2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
140
+ 29
141
+ }
142
+ 2 => 28,
143
+ 4 | 6 | 9 | 11 => 30,
144
+ _ => 31,
145
+ }
146
+ }
147
+
148
+ /// The calendar date `days` after 1970-01-01: the inverse of
149
+ /// [`days_from_civil`] (Howard Hinnant's `civil_from_days`).
150
+ const fn civil_from_days(days: i64) -> (i64, i64, i64) {
151
+ let days = days + 719_468;
152
+ let era = days.div_euclid(146_097);
153
+ let day_of_era = days - era * 146_097;
154
+ let year_of_era =
155
+ (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
156
+ let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
157
+ let shifted_month = (5 * day_of_year + 2) / 153;
158
+ let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
159
+ let month = if shifted_month < 10 { shifted_month + 3 } else { shifted_month - 9 };
160
+ let year = year_of_era + era * 400 + if month <= 2 { 1 } else { 0 };
161
+ (year, month, day)
162
+ }
163
+
164
+ /// Days from 1970-01-01 in the proleptic Gregorian calendar (Howard
165
+ /// Hinnant's `days_from_civil`).
166
+ const fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
167
+ let year = if month <= 2 { year - 1 } else { year };
168
+ let era = year.div_euclid(400);
169
+ let year_of_era = year - era * 400;
170
+ let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
171
+ let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
172
+ era * 146_097 + day_of_era - 719_468
173
+ }
@@ -0,0 +1,226 @@
1
+ //! How a cell looks: number format, font, fill, alignment and border.
2
+
3
+ #[cfg(test)]
4
+ mod tests;
5
+
6
+ use crate::style::{Align, Color, TextDirection};
7
+ use crate::{Error, Result};
8
+
9
+ /// Excel's limit on a number format code.
10
+ const MAX_NUMBER_FORMAT: usize = 255;
11
+ /// Excel's limit on a font name.
12
+ const MAX_FONT_NAME: usize = 31;
13
+ /// The largest font size Excel accepts, in points.
14
+ const MAX_FONT_SIZE: f64 = 409.0;
15
+ /// The font of unformatted cells.
16
+ pub const DEFAULT_FONT: &str = "Calibri";
17
+ /// Its size, in half points.
18
+ const DEFAULT_HALF_POINTS: u16 = 22;
19
+
20
+ /// Vertical alignment within the cell. Excel's default is the bottom.
21
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22
+ pub enum VerticalAlign {
23
+ Top,
24
+ Center,
25
+ Bottom,
26
+ }
27
+
28
+ impl VerticalAlign {
29
+ pub(crate) const fn as_str(self) -> &'static str {
30
+ match self {
31
+ Self::Top => "top",
32
+ Self::Center => "center",
33
+ Self::Bottom => "bottom",
34
+ }
35
+ }
36
+ }
37
+
38
+ /// A line on all four sides of the cell.
39
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40
+ pub enum Border {
41
+ Thin,
42
+ Medium,
43
+ Thick,
44
+ }
45
+
46
+ impl Border {
47
+ pub(crate) const fn as_str(self) -> &'static str {
48
+ match self {
49
+ Self::Thin => "thin",
50
+ Self::Medium => "medium",
51
+ Self::Thick => "thick",
52
+ }
53
+ }
54
+ }
55
+
56
+ /// A cell format, built up from the default look:
57
+ ///
58
+ /// ```
59
+ /// use imprimatur::xlsx::{Align, Color, Format};
60
+ ///
61
+ /// let money = Format::new().number_format("#,##0.00")?.bold().fill(Color::rgb(0xFFF2CC));
62
+ /// let header = Format::new().bold().align(Align::Center);
63
+ /// # Ok::<(), imprimatur::Error>(())
64
+ /// ```
65
+ #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
66
+ pub struct Format {
67
+ pub(crate) number_code: Option<String>,
68
+ pub(crate) font: Font,
69
+ pub(crate) fill: Option<Color>,
70
+ pub(crate) align: Option<Align>,
71
+ pub(crate) valign: Option<VerticalAlign>,
72
+ pub(crate) direction: Option<TextDirection>,
73
+ pub(crate) wrap: bool,
74
+ pub(crate) border: Option<Border>,
75
+ }
76
+
77
+ impl Format {
78
+ #[must_use]
79
+ pub fn new() -> Self {
80
+ Self::default()
81
+ }
82
+
83
+ /// An Excel number format code, such as `0.00`, `#,##0`, `0%` or
84
+ /// `yyyy-mm-dd`. `General` is the default.
85
+ ///
86
+ /// # Errors
87
+ /// [`Error::InvalidNumberFormat`] when empty, over 255 characters, or
88
+ /// holding a control character.
89
+ pub fn number_format(mut self, code: impl Into<String>) -> Result<Self> {
90
+ let code = code.into();
91
+ let length = code.chars().count();
92
+ if length == 0 || length > MAX_NUMBER_FORMAT || code.chars().any(char::is_control) {
93
+ return Err(Error::InvalidNumberFormat(code));
94
+ }
95
+ self.number_code = Some(code);
96
+ Ok(self)
97
+ }
98
+
99
+ #[must_use]
100
+ pub const fn bold(mut self) -> Self {
101
+ self.font.bold = true;
102
+ self
103
+ }
104
+
105
+ #[must_use]
106
+ pub const fn italic(mut self) -> Self {
107
+ self.font.italic = true;
108
+ self
109
+ }
110
+
111
+ #[must_use]
112
+ pub const fn underline(mut self) -> Self {
113
+ self.font.underline = true;
114
+ self
115
+ }
116
+
117
+ #[must_use]
118
+ pub const fn font_color(mut self, color: Color) -> Self {
119
+ self.font.color = Some(color);
120
+ self
121
+ }
122
+
123
+ /// The typeface, such as `Arial`; Calibri by default. Excel substitutes
124
+ /// another font when the reader's machine lacks it.
125
+ ///
126
+ /// # Errors
127
+ /// [`Error::InvalidFontName`] when empty, over 31 characters, or holding
128
+ /// a control character.
129
+ pub fn font_name(mut self, name: impl Into<String>) -> Result<Self> {
130
+ let name = name.into();
131
+ let length = name.chars().count();
132
+ if length == 0 || length > MAX_FONT_NAME || name.chars().any(char::is_control) {
133
+ return Err(Error::InvalidFontName(name));
134
+ }
135
+ self.font.name = (name != DEFAULT_FONT).then_some(name);
136
+ Ok(self)
137
+ }
138
+
139
+ /// The size in points, 11 by default.
140
+ ///
141
+ /// # Errors
142
+ /// [`Error::InvalidFontSize`] outside 1 to 409, or between half points.
143
+ pub fn font_size(mut self, points: f64) -> Result<Self> {
144
+ let half_points = points * 2.0;
145
+ let valid = (1.0..=MAX_FONT_SIZE).contains(&points) && half_points.fract() == 0.0;
146
+ valid.ok_or(Error::InvalidFontSize(points))?;
147
+ #[expect(clippy::cast_possible_truncation, reason = "a whole number from 2 to 818")]
148
+ #[expect(clippy::cast_sign_loss, reason = "a whole number from 2 to 818")]
149
+ let half_points = half_points as u16;
150
+ self.font.half_points = (half_points != DEFAULT_HALF_POINTS).then_some(half_points);
151
+ Ok(self)
152
+ }
153
+
154
+ /// A solid background.
155
+ #[must_use]
156
+ pub const fn fill(mut self, color: Color) -> Self {
157
+ self.fill = Some(color);
158
+ self
159
+ }
160
+
161
+ #[must_use]
162
+ pub const fn align(mut self, align: Align) -> Self {
163
+ self.align = Some(align);
164
+ self
165
+ }
166
+
167
+ #[must_use]
168
+ pub const fn valign(mut self, valign: VerticalAlign) -> Self {
169
+ self.valign = Some(valign);
170
+ self
171
+ }
172
+
173
+ #[must_use]
174
+ pub const fn direction(mut self, direction: TextDirection) -> Self {
175
+ self.direction = Some(direction);
176
+ self
177
+ }
178
+
179
+ /// Wraps text onto several lines within the column width.
180
+ #[must_use]
181
+ pub const fn wrap(mut self) -> Self {
182
+ self.wrap = true;
183
+ self
184
+ }
185
+
186
+ #[must_use]
187
+ pub const fn border(mut self, border: Border) -> Self {
188
+ self.border = Some(border);
189
+ self
190
+ }
191
+
192
+ pub(crate) const fn has_number_format(&self) -> bool {
193
+ self.number_code.is_some()
194
+ }
195
+
196
+ /// Whether the format needs an `<alignment>` element.
197
+ pub(crate) const fn has_alignment(&self) -> bool {
198
+ self.align.is_some() || self.valign.is_some() || self.direction.is_some() || self.wrap
199
+ }
200
+ }
201
+
202
+ /// The part of a format that lives in `<fonts>`. The default typeface and
203
+ /// size are `None`, so a format naming them equals one that does not.
204
+ #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
205
+ pub struct Font {
206
+ pub(crate) bold: bool,
207
+ pub(crate) italic: bool,
208
+ pub(crate) underline: bool,
209
+ pub(crate) color: Option<Color>,
210
+ pub(crate) name: Option<String>,
211
+ /// Points times two: sizes come in steps of 0.5.
212
+ pub(crate) half_points: Option<u16>,
213
+ }
214
+
215
+ impl Font {
216
+ pub(crate) fn name(&self) -> &str {
217
+ self.name.as_deref().unwrap_or(DEFAULT_FONT)
218
+ }
219
+
220
+ /// The size in points as `<sz>` writes it: `11`, `10.5`.
221
+ pub(crate) fn size(&self) -> String {
222
+ let half_points = self.half_points.unwrap_or(DEFAULT_HALF_POINTS);
223
+ let whole = half_points / 2;
224
+ if half_points.is_multiple_of(2) { whole.to_string() } else { format!("{whole}.5") }
225
+ }
226
+ }
@@ -0,0 +1,268 @@
1
+ //! The parts of an `.xlsx` package and the relationships between them.
2
+
3
+ use std::fmt::Write as _;
4
+ use std::io::Write;
5
+
6
+ use super::cell::{Cell, cell_reference, push_cell_reference};
7
+ use super::comment;
8
+ use super::styles::Stylesheet;
9
+ use super::text::{SharedStrings, escape, needs_preserve};
10
+ use super::validation;
11
+ use super::worksheet::Worksheet;
12
+ use crate::opc;
13
+ use crate::xml::XmlWriter;
14
+ use crate::zip::ZipWriter;
15
+ use crate::{Error, Result};
16
+
17
+ pub const MAIN_NS: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
18
+
19
+ const SPREADSHEET_TYPE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml";
20
+ pub const VML_TYPE: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing";
21
+ /// A sheet's own relationships: its comments, then their drawing.
22
+ const SHEET_RELATIONSHIPS: [(&str, &str); 2] =
23
+ [("comments", "../comments{n}.xml"), ("vmlDrawing", "../drawings/vmlDrawing{n}.vml")];
24
+ const DRAWING_ID: &str = "rId2";
25
+
26
+ /// Writes the whole package. Each sheet becomes `xl/worksheets/sheetN.xml`,
27
+ /// followed by its comments and their drawing if it has any, and is
28
+ /// compressed and dropped before the next is built, so only one sheet's XML
29
+ /// is in memory at a time; the styles and the shared string table, complete
30
+ /// only after every sheet, follow them.
31
+ pub fn write<W: Write>(sheets: &[Worksheet], out: W) -> Result<W> {
32
+ if sheets.is_empty() {
33
+ return Err(Error::EmptyWorkbook);
34
+ }
35
+ let has_strings = sheets.iter().any(|sheet| {
36
+ sheet
37
+ .rows()
38
+ .any(|(_, cells)| cells.values().any(|entry| matches!(entry.cell, Cell::Text(_))))
39
+ });
40
+
41
+ let mut zip = ZipWriter::new(out);
42
+ zip.add("[Content_Types].xml", content_types(sheets, has_strings).as_bytes())?;
43
+ zip.add(
44
+ "_rels/.rels",
45
+ opc::relationships(&[("officeDocument", "xl/workbook.xml")]).as_bytes(),
46
+ )?;
47
+ zip.add("xl/workbook.xml", workbook(sheets).as_bytes())?;
48
+ zip.add(
49
+ "xl/_rels/workbook.xml.rels",
50
+ workbook_relationships(sheets.len(), has_strings).as_bytes(),
51
+ )?;
52
+ let mut strings = SharedStrings::default();
53
+ let mut styles = Stylesheet::default();
54
+ let mut name = String::new();
55
+ // VML shape ids are unique across the workbook, in blocks of 1024.
56
+ let mut next_block = 1;
57
+ for (number, sheet) in (1..).zip(sheets) {
58
+ let remap = styles.add_sheet(sheet)?;
59
+ set(&mut name, format_args!("xl/worksheets/sheet{number}.xml"));
60
+ zip.add(&name, worksheet(sheet, &remap, &mut strings).as_bytes())?;
61
+ let comments = sheet.comments();
62
+ if comments.is_empty() {
63
+ continue;
64
+ }
65
+ let targets = SHEET_RELATIONSHIPS
66
+ .map(|(kind, target)| (kind, target.replace("{n}", &number.to_string())));
67
+ set(&mut name, format_args!("xl/worksheets/_rels/sheet{number}.xml.rels"));
68
+ zip.add(&name, opc::relationships(&targets).as_bytes())?;
69
+ set(&mut name, format_args!("xl/comments{number}.xml"));
70
+ zip.add(&name, comment::comments_xml(comments).as_bytes())?;
71
+ set(&mut name, format_args!("xl/drawings/vmlDrawing{number}.vml"));
72
+ let drawing = comment::vml(comments, sheet.column_widths(), next_block);
73
+ zip.add(&name, drawing.as_bytes())?;
74
+ next_block += comment::blocks_for(comments.len());
75
+ }
76
+ 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())?;
78
+ if has_strings {
79
+ zip.add("xl/sharedStrings.xml", strings.to_xml().as_bytes())?;
80
+ }
81
+ zip.finish()
82
+ }
83
+
84
+ /// One override per part: the workbook, its styles, shared strings, sheets
85
+ /// and comments. Drawings, by their `.vml` extension.
86
+ fn content_types(sheets: &[Worksheet], has_strings: bool) -> String {
87
+ let mut parts = vec![
88
+ ("/xl/workbook.xml".to_owned(), "sheet.main"),
89
+ ("/xl/styles.xml".to_owned(), "styles"),
90
+ ];
91
+ if has_strings {
92
+ parts.push(("/xl/sharedStrings.xml".to_owned(), "sharedStrings"));
93
+ }
94
+ let mut commented = false;
95
+ for (number, sheet) in (1..).zip(sheets) {
96
+ parts.push((format!("/xl/worksheets/sheet{number}.xml"), "worksheet"));
97
+ if !sheet.comments().is_empty() {
98
+ parts.push((format!("/xl/comments{number}.xml"), "comments"));
99
+ commented = true;
100
+ }
101
+ }
102
+ let overrides: Vec<(String, String)> = parts
103
+ .into_iter()
104
+ .map(|(part, kind)| (part, format!("{SPREADSHEET_TYPE}.{kind}+xml")))
105
+ .collect();
106
+ let defaults: &[(&str, &str)] = if commented { &[("vml", VML_TYPE)] } else { &[] };
107
+ opc::content_types(defaults, &overrides)
108
+ }
109
+
110
+ /// Sheets are `rId1`..`rIdN`, then styles, then shared strings.
111
+ fn workbook_relationships(sheet_count: usize, has_strings: bool) -> String {
112
+ let mut targets: Vec<(&str, String)> = (1..=sheet_count)
113
+ .map(|number| ("worksheet", format!("worksheets/sheet{number}.xml")))
114
+ .collect();
115
+ targets.push(("styles", "styles.xml".to_owned()));
116
+ if has_strings {
117
+ targets.push(("sharedStrings", "sharedStrings.xml".to_owned()));
118
+ }
119
+ opc::relationships(&targets)
120
+ }
121
+
122
+ fn workbook(sheets: &[Worksheet]) -> String {
123
+ let mut xml = XmlWriter::new();
124
+ xml.open("workbook", &[("xmlns", MAIN_NS), ("xmlns:r", opc::RELATIONSHIPS)]);
125
+ xml.open("sheets", &[]);
126
+ for (index, sheet) in sheets.iter().enumerate() {
127
+ let number = (index + 1).to_string();
128
+ let id = format!("rId{number}");
129
+ xml.empty("sheet", &[("name", sheet.name()), ("sheetId", &number), ("r:id", &id)]);
130
+ }
131
+ xml.close("sheets");
132
+ // Formulas are written without cached results: have Excel compute them.
133
+ xml.empty("calcPr", &[("fullCalcOnLoad", "1")]);
134
+ xml.close("workbook");
135
+ xml.finish()
136
+ }
137
+
138
+ /// `remap` turns the sheet's own style ids into the workbook's. The cell
139
+ /// loop formats into three scratch buffers instead of allocating per cell.
140
+ fn worksheet<'a>(sheet: &'a Worksheet, remap: &[u32], strings: &mut SharedStrings<'a>) -> String {
141
+ let mut xml = XmlWriter::new();
142
+ xml.open("worksheet", &[("xmlns", MAIN_NS), ("xmlns:r", opc::RELATIONSHIPS)]);
143
+ xml.empty("dimension", &[("ref", &dimension(sheet))]);
144
+ if sheet.is_right_to_left() {
145
+ xml.open("sheetViews", &[]);
146
+ xml.empty("sheetView", &[("rightToLeft", "1"), ("workbookViewId", "0")]);
147
+ xml.close("sheetViews");
148
+ }
149
+ write_columns(&mut xml, sheet);
150
+ xml.open("sheetData", &[]);
151
+ let (mut reference, mut style, mut value) = (String::new(), String::new(), String::new());
152
+ for (row, cells) in sheet.rows() {
153
+ set(&mut value, u64::from(row) + 1);
154
+ xml.open("row", &[("r", &value)]);
155
+ for (&column, entry) in cells {
156
+ reference.clear();
157
+ push_cell_reference(&mut reference, row, column);
158
+ // Every style id a cell holds came from the sheet's own table.
159
+ let id = remap.get(entry.style as usize).copied().unwrap_or_default();
160
+ set(&mut style, id);
161
+ let styled = id != 0;
162
+ let (kind, text) = match &entry.cell {
163
+ Cell::Text(text) => {
164
+ set(&mut value, strings.intern(text));
165
+ (Some("s"), None)
166
+ }
167
+ Cell::Number(number) => {
168
+ set(&mut value, number);
169
+ (None, None)
170
+ }
171
+ Cell::DateTime(date) => {
172
+ set(&mut value, date.serial());
173
+ (None, None)
174
+ }
175
+ Cell::Bool(flag) => {
176
+ set(&mut value, u8::from(*flag));
177
+ (Some("b"), None)
178
+ }
179
+ Cell::Formula(formula) => (None, Some(formula)),
180
+ };
181
+ let attrs =
182
+ [("r", reference.as_str()), ("s", style.as_str()), ("t", kind.unwrap_or(""))];
183
+ let attrs: &[(&str, &str)] = match (styled, kind.is_some()) {
184
+ (true, true) => &attrs,
185
+ (true, false) => &attrs[..2],
186
+ (false, true) => &[attrs[0], attrs[2]],
187
+ (false, false) => &attrs[..1],
188
+ };
189
+ xml.open("c", attrs);
190
+ match text {
191
+ Some(formula) => {
192
+ let preserve: &[(&str, &str)] =
193
+ if needs_preserve(formula) { &[("xml:space", "preserve")] } else { &[] };
194
+ xml.element("f", preserve, &escape(formula));
195
+ }
196
+ None => xml.element("v", &[], &value),
197
+ }
198
+ xml.close("c");
199
+ }
200
+ xml.close("row");
201
+ }
202
+ xml.close("sheetData");
203
+ validation::write_all(&mut xml, sheet.validations());
204
+ if !sheet.comments().is_empty() {
205
+ xml.empty("legacyDrawing", &[("r:id", DRAWING_ID)]);
206
+ }
207
+ xml.close("worksheet");
208
+ xml.finish()
209
+ }
210
+
211
+ /// Replaces `buffer` with `value`'s text, reusing its allocation.
212
+ fn set(buffer: &mut String, value: impl std::fmt::Display) {
213
+ buffer.clear();
214
+ // Writing to a String cannot fail.
215
+ let _ = write!(buffer, "{value}");
216
+ }
217
+
218
+ /// `<cols>`: one `<col>` per run of adjacent columns with the same width.
219
+ fn write_columns(xml: &mut XmlWriter, sheet: &Worksheet) {
220
+ let widths = sheet.column_widths();
221
+ if widths.is_empty() {
222
+ return;
223
+ }
224
+ let mut runs: Vec<(u16, u16, f64)> = Vec::new();
225
+ for (&column, &width) in widths {
226
+ match runs.last_mut() {
227
+ Some((_, last, run_width))
228
+ if *last + 1 == column && run_width.to_bits() == width.to_bits() =>
229
+ {
230
+ *last = column;
231
+ }
232
+ _ => runs.push((column, column, width)),
233
+ }
234
+ }
235
+ xml.open("cols", &[]);
236
+ for (first, last, width) in runs {
237
+ // <col> numbers columns from 1.
238
+ let (min, max) = ((first + 1).to_string(), (last + 1).to_string());
239
+ let width = width.to_string();
240
+ xml.empty("col", &[("min", &min), ("max", &max), ("width", &width), ("customWidth", "1")]);
241
+ }
242
+ xml.close("cols");
243
+ }
244
+
245
+ /// The used range, `A1` for an empty sheet as Excel writes it.
246
+ fn dimension(sheet: &Worksheet) -> String {
247
+ let mut bounds: Option<(u32, u16, u32, u16)> = None;
248
+ for (row, cells) in sheet.rows() {
249
+ let (Some((&first, _)), Some((&last, _))) =
250
+ (cells.first_key_value(), cells.last_key_value())
251
+ else {
252
+ continue;
253
+ };
254
+ bounds = Some(match bounds {
255
+ None => (row, first, row, last),
256
+ Some((top, left, _, right)) => (top, left.min(first), row, right.max(last)),
257
+ });
258
+ }
259
+ let Some((top, left, bottom, right)) = bounds else {
260
+ return "A1".to_owned();
261
+ };
262
+ let mut range = cell_reference(top, left);
263
+ if (top, left) != (bottom, right) {
264
+ // Writing to a String cannot fail.
265
+ let _ = write!(range, ":{}", cell_reference(bottom, right));
266
+ }
267
+ range
268
+ }