fast_xlsx 0.4.0 → 0.6.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.
@@ -13,7 +13,7 @@ use rust_xlsxwriter::{
13
13
  ConditionalFormatCell, ConditionalFormatCellRule, ConditionalFormatDataBar,
14
14
  ConditionalFormatFormula, ConditionalFormatText, ConditionalFormatTextRule,
15
15
  ConditionalFormatValue, DataValidation, DataValidationRule, FormatAlign, FormatBorder,
16
- FormatScript, FormatUnderline, IntoDataValidationValue, IntoExcelData, XlsxError,
16
+ FormatScript, FormatUnderline, IgnoreError, IntoDataValidationValue, IntoExcelData, XlsxError,
17
17
  };
18
18
 
19
19
  use rust_xlsxwriter::{
@@ -34,9 +34,52 @@ static FORMULA: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "Formula")
34
34
  static URL: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "URL"));
35
35
  static RICH_STRING: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "RichString"));
36
36
 
37
+ // A row or column outside the sheet is a RangeError, like a negative one
38
+ // (which fails converting to an unsigned number before reaching the writer).
37
39
  fn xerr(e: XlsxError) -> Error {
38
40
  let ruby = Ruby::get().unwrap();
39
- Error::new(ruby.get_inner(&ERROR), e.to_string())
41
+ let class = match e {
42
+ XlsxError::RowColumnLimitError => ruby.exception_range_error(),
43
+ // Values that aren't allowed, like a reversed range.
44
+ XlsxError::RowColumnOrderError | XlsxError::MergeRangeSingleCell => {
45
+ ruby.exception_arg_error()
46
+ }
47
+ _ => ruby.get_inner(&ERROR),
48
+ };
49
+ Error::new(class, e.to_string())
50
+ }
51
+
52
+ // Excel's limits that rust_xlsxwriter doesn't check: a negative width or
53
+ // height hides the column or row, a larger one is capped or invalid.
54
+ // A size that must be a finite number above 0 (NaN or a negative one would
55
+ // be written into the file as is).
56
+ fn check_positive(ruby: &Ruby, what: &str, size: f64) -> Result<f64, Error> {
57
+ if size.is_finite() && size > 0.0 {
58
+ return Ok(size);
59
+ }
60
+ Err(Error::new(
61
+ ruby.exception_arg_error(),
62
+ format!("invalid {what} {size}: use a number above 0"),
63
+ ))
64
+ }
65
+
66
+ // A range of rows is checked before any row changes, so it is all or nothing.
67
+ fn check_last_row(last: u32) -> Result<(), Error> {
68
+ if last < MAX_ROWS {
69
+ Ok(())
70
+ } else {
71
+ Err(xerr(XlsxError::RowColumnLimitError))
72
+ }
73
+ }
74
+
75
+ fn check_size(ruby: &Ruby, what: &str, size: f64, max: f64) -> Result<(), Error> {
76
+ if (0.0..=max).contains(&size) {
77
+ return Ok(());
78
+ }
79
+ Err(Error::new(
80
+ ruby.exception_arg_error(),
81
+ format!("invalid {what} {size}: use 0..{max}"),
82
+ ))
40
83
  }
41
84
 
42
85
  type Shared = Arc<Mutex<rust_xlsxwriter::Workbook>>;
@@ -113,14 +156,20 @@ struct Worksheet {
113
156
  table_formats: RefCell<Vec<TableColumnFormat>>,
114
157
  // Merged ranges as (first_row, first_col, last_row, last_col).
115
158
  merges: RefCell<Vec<(u32, u16, u32, u16)>>,
116
- // Column ranges given a format with column_format.
117
- formatted_columns: RefCell<Vec<(u16, u16)>>,
159
+ // Formats given with column_format: (first, last, style).
160
+ formatted_columns: RefCell<Vec<(u16, u16, Arc<Style>)>>,
161
+ // Formats given with row_format: (first, last, style).
162
+ formatted_rows: RefCell<Vec<(u32, u32, Arc<Style>)>>,
163
+ // Whether a row got options (format, hidden, outline level):
164
+ // rust_xlsxwriter copies the default row height into a row then, so
165
+ // default_row_height must come first.
166
+ row_options_set: Cell<bool>,
118
167
  }
119
168
 
120
169
  struct TableColumnFormat {
121
170
  rows: std::ops::RangeInclusive<u32>,
122
171
  col: u16,
123
- format: Arc<rust_xlsxwriter::Format>,
172
+ format: Arc<Style>,
124
173
  }
125
174
 
126
175
  impl Workbook {
@@ -183,6 +232,8 @@ impl Workbook {
183
232
  table_formats: RefCell::new(Vec::new()),
184
233
  merges: RefCell::new(Vec::new()),
185
234
  formatted_columns: RefCell::new(Vec::new()),
235
+ formatted_rows: RefCell::new(Vec::new()),
236
+ row_options_set: Cell::new(false),
186
237
  })
187
238
  }
188
239
 
@@ -307,7 +358,7 @@ enum CellValue {
307
358
  Formula(String),
308
359
  Url(String, Option<String>),
309
360
  // RichString segments; None means the default font.
310
- Rich(Vec<(Option<Arc<rust_xlsxwriter::Format>>, String)>),
361
+ Rich(Vec<(Option<Arc<Style>>, String)>),
311
362
  }
312
363
 
313
364
  impl CellValue {
@@ -348,16 +399,10 @@ impl CellValue {
348
399
  Ok(value)
349
400
  }
350
401
 
351
- // The format for a value written without one: without a number format
352
- // Excel shows a date as its serial number.
353
- fn default_format(&self) -> Option<&'static rust_xlsxwriter::Format> {
354
- static DATE: LazyLock<rust_xlsxwriter::Format> =
355
- LazyLock::new(|| rust_xlsxwriter::Format::new().set_num_format("yyyy-mm-dd"));
356
- static DATE_TIME: LazyLock<rust_xlsxwriter::Format> =
357
- LazyLock::new(|| rust_xlsxwriter::Format::new().set_num_format("yyyy-mm-dd hh:mm:ss"));
402
+ // Some(has a time of day) for date cells.
403
+ fn date_kind(&self) -> Option<bool> {
358
404
  match self {
359
- CellValue::Date(_, false) => Some(&DATE),
360
- CellValue::Date(_, true) => Some(&DATE_TIME),
405
+ CellValue::Date(_, time) => Some(*time),
361
406
  _ => None,
362
407
  }
363
408
  }
@@ -390,7 +435,11 @@ impl CellValue {
390
435
  format: Option<&rust_xlsxwriter::Format>,
391
436
  ) -> Result<(), Error> {
392
437
  match self {
393
- CellValue::Empty => Ok(()),
438
+ // nil writes nothing, unless it has a format (e.g. a border).
439
+ CellValue::Empty => match format {
440
+ Some(f) => ws.write_blank(row, col, f).map(|_| ()).map_err(xerr),
441
+ None => Ok(()),
442
+ },
394
443
  CellValue::Text(s) => emit(ws, row, col, s.as_str(), format),
395
444
  CellValue::Number(n) | CellValue::Date(n, _) => emit(ws, row, col, *n, format),
396
445
  CellValue::Bool(b) => emit(ws, row, col, *b, format),
@@ -412,7 +461,7 @@ impl CellValue {
412
461
  let default = rust_xlsxwriter::Format::default();
413
462
  let rich: Vec<(&rust_xlsxwriter::Format, &str)> = parts
414
463
  .iter()
415
- .map(|(f, text)| (f.as_deref().unwrap_or(&default), text.as_str()))
464
+ .map(|(f, text)| (f.as_deref().map_or(&default, |s| &s.format), text.as_str()))
416
465
  .collect();
417
466
  match format {
418
467
  Some(f) => ws.write_rich_string_with_format(row, col, &rich, f),
@@ -583,7 +632,7 @@ fn check_header_footer(ruby: &Ruby, what: &str, text: &str) -> Result<(), Error>
583
632
  let len = expanded.chars().count();
584
633
  if len > 255 {
585
634
  return Err(Error::new(
586
- ruby.exception_arg_error(),
635
+ ruby.get_inner(&ERROR),
587
636
  format!("{what} is {len} characters; Excel allows 255"),
588
637
  ));
589
638
  }
@@ -714,10 +763,7 @@ const TABLE_TOTALS: &[(&str, TableFunction)] = &[
714
763
 
715
764
  // A table column: a header String, or { header:, total:, total_label:, format: }.
716
765
  // Also returns a copy of the column's format, if any, for later writes.
717
- fn table_column(
718
- ruby: &Ruby,
719
- v: Value,
720
- ) -> Result<(TableColumn, Option<Arc<rust_xlsxwriter::Format>>), Error> {
766
+ fn table_column(ruby: &Ruby, v: Value) -> Result<(TableColumn, Option<Arc<Style>>), Error> {
721
767
  let Some(spec) = RHash::from_value(v) else {
722
768
  return Ok((TableColumn::new().set_header(String::try_convert(v)?), None));
723
769
  };
@@ -737,7 +783,7 @@ fn table_column(
737
783
  }
738
784
  let format = opt::<&Format>(ruby, spec, "format")?.map(|f| f.0.clone());
739
785
  if let Some(f) = &format {
740
- column = column.set_format(&**f);
786
+ column = column.set_format(&f.format);
741
787
  }
742
788
  Ok((column, format))
743
789
  }
@@ -761,6 +807,29 @@ const PROTECTION_ALLOW: &[(&str, ProtectionFlag)] = &[
761
807
  ("edit_objects", |o| &mut o.edit_objects),
762
808
  ];
763
809
 
810
+ // Not IgnoreError::TwoDigitTextYear: rust_xlsxwriter 0.99 writes it as
811
+ // "TwoDigitTextYear", not the schema's "twoDigitTextYear", which Excel would
812
+ // flag as damaged content. Add it once that is fixed upstream.
813
+ const IGNORE_ERRORS: &[(&str, IgnoreError)] = &[
814
+ ("number_stored_as_text", IgnoreError::NumberStoredAsText),
815
+ ("formula_error", IgnoreError::FormulaError),
816
+ ("formula_differs", IgnoreError::FormulaDiffers),
817
+ (
818
+ "formula_refers_to_empty_cells",
819
+ IgnoreError::FormulaRefersToEmptyCells,
820
+ ),
821
+ ("formula_omits_cells", IgnoreError::FormulaOmitsCells),
822
+ ("data_validation_error", IgnoreError::DataValidationError),
823
+ (
824
+ "unlocked_cells_with_formula",
825
+ IgnoreError::UnlockedCellsWithFormula,
826
+ ),
827
+ (
828
+ "inconsistent_column_formula",
829
+ IgnoreError::InconsistentColumnFormula,
830
+ ),
831
+ ];
832
+
764
833
  // Excel's paper size codes; page_setup also takes the number itself.
765
834
  const PAPER_SIZES: &[(&str, u8)] = &[
766
835
  ("letter", 1),
@@ -782,11 +851,52 @@ const BORDERS: &[(&str, FormatBorder)] = &[
782
851
  ];
783
852
 
784
853
  #[magnus::wrap(class = "FastXlsx::Format", free_immediately)]
785
- struct Format(Arc<rust_xlsxwriter::Format>);
854
+ struct Format(Arc<Style>);
855
+
856
+ // A Format as written: the format itself, and for one without a num_format,
857
+ // copies with the default date formats added, used for date cells (Excel
858
+ // shows a date with no number format as its serial number).
859
+ struct Style {
860
+ format: rust_xlsxwriter::Format,
861
+ date: Option<rust_xlsxwriter::Format>,
862
+ date_time: Option<rust_xlsxwriter::Format>,
863
+ }
864
+
865
+ const DATE_FORMAT: &str = "yyyy-mm-dd";
866
+ const DATE_TIME_FORMAT: &str = "yyyy-mm-dd hh:mm:ss";
867
+
868
+ impl Style {
869
+ fn new(format: rust_xlsxwriter::Format, has_num_format: bool) -> Self {
870
+ let with =
871
+ |num_format: &str| (!has_num_format).then(|| format.clone().set_num_format(num_format));
872
+ Style {
873
+ date: with(DATE_FORMAT),
874
+ date_time: with(DATE_TIME_FORMAT),
875
+ format,
876
+ }
877
+ }
878
+
879
+ // The date variant for a date cell (`time`: it has a time of day), if
880
+ // this format has no num_format of its own.
881
+ fn for_date(&self, time: bool) -> Option<&rust_xlsxwriter::Format> {
882
+ if time {
883
+ self.date_time.as_ref()
884
+ } else {
885
+ self.date.as_ref()
886
+ }
887
+ }
888
+
889
+ // What a value is written with: `date` is Some(has time) for date cells.
890
+ fn pick(&self, date: Option<bool>) -> &rust_xlsxwriter::Format {
891
+ date.and_then(|time| self.for_date(time))
892
+ .unwrap_or(&self.format)
893
+ }
894
+ }
786
895
 
787
896
  impl Format {
788
897
  fn new(ruby: &Ruby, options: RHash) -> Result<Self, Error> {
789
898
  let mut f = rust_xlsxwriter::Format::new();
899
+ let mut has_num_format = false;
790
900
  options.foreach(|key: Symbol, value: Value| {
791
901
  let taken = std::mem::take(&mut f);
792
902
  f = match &*key.name()? {
@@ -834,8 +944,15 @@ impl Format {
834
944
  "locked" => taken.set_unlocked(),
835
945
  "hidden" if value.to_bool() => taken.set_hidden(),
836
946
  "border_color" => taken.set_border_color(color(ruby, value)?),
837
- "num_format" => taken.set_num_format(String::try_convert(value)?),
838
- "font_size" => taken.set_font_size(f64::try_convert(value)?),
947
+ "num_format" => {
948
+ has_num_format = true;
949
+ taken.set_num_format(String::try_convert(value)?)
950
+ }
951
+ "font_size" => taken.set_font_size(check_positive(
952
+ ruby,
953
+ "font_size",
954
+ f64::try_convert(value)?,
955
+ )?),
839
956
  "font_name" => taken.set_font_name(String::try_convert(value)?),
840
957
  "font_color" => taken.set_font_color(color(ruby, value)?),
841
958
  "bg_color" => taken.set_background_color(color(ruby, value)?),
@@ -876,7 +993,7 @@ impl Format {
876
993
  };
877
994
  Ok(ForEach::Continue)
878
995
  })?;
879
- Ok(Format(Arc::new(f)))
996
+ Ok(Format(Arc::new(Style::new(f, has_num_format))))
880
997
  }
881
998
  }
882
999
 
@@ -899,7 +1016,7 @@ fn each_entry(
899
1016
 
900
1017
  // A row's format: one Format (or nil) for every cell, or an Array with one per cell.
901
1018
  enum RowFormat {
902
- Same(Option<Arc<rust_xlsxwriter::Format>>),
1019
+ Same(Option<Arc<Style>>),
903
1020
  PerCell(RArray),
904
1021
  }
905
1022
 
@@ -913,7 +1030,7 @@ impl RowFormat {
913
1030
  }
914
1031
  }
915
1032
 
916
- fn at(&self, col: usize) -> Result<Option<Arc<rust_xlsxwriter::Format>>, Error> {
1033
+ fn at(&self, col: usize) -> Result<Option<Arc<Style>>, Error> {
917
1034
  match self {
918
1035
  RowFormat::Same(f) => Ok(f.clone()),
919
1036
  RowFormat::PerCell(formats) => Ok(Option::<&Format>::try_convert(
@@ -955,42 +1072,52 @@ impl Worksheet {
955
1072
  f(wb.worksheet_from_index(self.index).map_err(xerr)?)
956
1073
  }
957
1074
 
958
- // The format to write a cell with: its own, else its table column's.
959
- fn cell_format<'a>(
1075
+ // The format a value is written with: its own, else its table column's.
1076
+ // A date cell takes that format's date variant (when it has no
1077
+ // num_format); with neither, its row_format's, else its column_format's
1078
+ // date variant (rust_xlsxwriter's order), else the default date format.
1079
+ // Other cells without one get None, so rust_xlsxwriter applies any row or
1080
+ // column format itself.
1081
+ fn format_for<'a>(
960
1082
  tables: &'a [TableColumnFormat],
1083
+ rows: &'a [(u32, u32, Arc<Style>)],
1084
+ columns: &'a [(u16, u16, Arc<Style>)],
961
1085
  row: u32,
962
1086
  col: u16,
963
- own: Option<&'a rust_xlsxwriter::Format>,
1087
+ own: Option<&'a Style>,
1088
+ value: &CellValue,
964
1089
  ) -> Option<&'a rust_xlsxwriter::Format> {
965
- own.or_else(|| {
1090
+ static DEFAULTS: LazyLock<Style> =
1091
+ LazyLock::new(|| Style::new(rust_xlsxwriter::Format::new(), false));
1092
+ let date = value.date_kind();
1093
+ let style = own.or_else(|| {
966
1094
  tables
967
1095
  .iter()
968
1096
  .rev()
969
1097
  .find(|t| t.col == col && t.rows.contains(&row))
970
1098
  .map(|t| &*t.format)
971
- })
972
- }
973
-
974
- // The format a value is written with: its own, else its table column's,
975
- // else its type's default (dates) unless column_format covers the column,
976
- // whose format rust_xlsxwriter applies to cells written without one.
977
- fn format_for<'a>(
978
- &self,
979
- tables: &'a [TableColumnFormat],
980
- row: u32,
981
- col: u16,
982
- own: Option<&'a rust_xlsxwriter::Format>,
983
- value: &CellValue,
984
- ) -> Option<&'a rust_xlsxwriter::Format> {
985
- Self::cell_format(tables, row, col, own).or_else(|| {
986
- value.default_format().filter(|_| {
987
- !self
988
- .formatted_columns
989
- .borrow()
990
- .iter()
991
- .any(|&(first, last)| (first..=last).contains(&col))
992
- })
993
- })
1099
+ });
1100
+ if let Some(style) = style {
1101
+ return Some(style.pick(date));
1102
+ }
1103
+ let time = date?;
1104
+ let row_style = rows
1105
+ .iter()
1106
+ .rev()
1107
+ .find(|(first, last, _)| (*first..=*last).contains(&row))
1108
+ .map(|(_, _, style)| style);
1109
+ let column_style = || {
1110
+ columns
1111
+ .iter()
1112
+ .rev()
1113
+ .find(|(first, last, _)| (*first..=*last).contains(&col))
1114
+ .map(|(_, _, style)| style)
1115
+ };
1116
+ match row_style.or_else(column_style) {
1117
+ // None when it has a num_format: rust_xlsxwriter applies it.
1118
+ Some(style) => style.for_date(time),
1119
+ None => DEFAULTS.for_date(time),
1120
+ }
994
1121
  }
995
1122
 
996
1123
  fn write_row(
@@ -1006,7 +1133,7 @@ impl Worksheet {
1006
1133
  each_entry(cells, |i, v| {
1007
1134
  if i >= MAX_COLS {
1008
1135
  return Err(Error::new(
1009
- ruby.exception_arg_error(),
1136
+ ruby.exception_range_error(),
1010
1137
  format!("too many columns: Excel allows {MAX_COLS}"),
1011
1138
  ));
1012
1139
  }
@@ -1015,11 +1142,21 @@ impl Worksheet {
1015
1142
  Ok(())
1016
1143
  })?;
1017
1144
  let tables = self.table_formats.borrow();
1145
+ let rows = self.formatted_rows.borrow();
1146
+ let columns = self.formatted_columns.borrow();
1018
1147
  self.with_ws(|ws| {
1019
1148
  // Values were checked when converted, so only a bad row number
1020
1149
  // fails here, and it fails on the first cell.
1021
1150
  for (col, value, format) in &values {
1022
- let format = self.format_for(&tables, row, *col, format.as_deref(), value);
1151
+ let format = Self::format_for(
1152
+ &tables,
1153
+ &rows,
1154
+ &columns,
1155
+ row,
1156
+ *col,
1157
+ format.as_deref(),
1158
+ value,
1159
+ );
1023
1160
  value.write(ws, row, *col, format)?;
1024
1161
  }
1025
1162
  Ok(())
@@ -1063,12 +1200,22 @@ impl Worksheet {
1063
1200
  rb_self.check_not_flushed(ruby, row)?;
1064
1201
  let value = CellValue::from_ruby(ruby, v)?;
1065
1202
  let tables = rb_self.table_formats.borrow();
1203
+ let rows = rb_self.formatted_rows.borrow();
1204
+ let columns = rb_self.formatted_columns.borrow();
1066
1205
  rb_self.with_ws(|ws| {
1067
1206
  value.write(
1068
1207
  ws,
1069
1208
  row,
1070
1209
  col,
1071
- rb_self.format_for(&tables, row, col, format.map(|f| &*f.0), &value),
1210
+ Self::format_for(
1211
+ &tables,
1212
+ &rows,
1213
+ &columns,
1214
+ row,
1215
+ col,
1216
+ format.map(|f| &*f.0),
1217
+ &value,
1218
+ ),
1072
1219
  )
1073
1220
  })?;
1074
1221
  rb_self.advance(row);
@@ -1100,21 +1247,167 @@ impl Worksheet {
1100
1247
  Ok(rb_self)
1101
1248
  }
1102
1249
 
1103
- fn set_column_width(&self, first: u16, last: u16, width: f64) -> Result<(), Error> {
1104
- self.with_ws(|ws| {
1250
+ fn set_column_width(
1251
+ ruby: &Ruby,
1252
+ rb_self: &Self,
1253
+ first: u16,
1254
+ last: u16,
1255
+ width: f64,
1256
+ ) -> Result<(), Error> {
1257
+ check_size(ruby, "column width", width, 255.0)?;
1258
+ rb_self.with_ws(|ws| {
1105
1259
  ws.set_column_range_width(first, last, width)
1106
1260
  .map(|_| ())
1107
1261
  .map_err(xerr)
1108
1262
  })
1109
1263
  }
1110
1264
 
1265
+ // Row options are written with the row, so rows on disk ignore them.
1266
+ fn hide_rows(
1267
+ ruby: &Ruby,
1268
+ rb_self: Obj<Self>,
1269
+ first: u32,
1270
+ last: u32,
1271
+ ) -> Result<Obj<Self>, Error> {
1272
+ rb_self.check_not_flushed(ruby, first)?;
1273
+ check_last_row(last)?;
1274
+ rb_self.with_ws(|ws| {
1275
+ for row in first..=last {
1276
+ ws.set_row_hidden(row).map_err(xerr)?;
1277
+ }
1278
+ Ok(())
1279
+ })?;
1280
+ rb_self.row_options_set.set(true);
1281
+ Ok(rb_self)
1282
+ }
1283
+
1284
+ fn selection(
1285
+ rb_self: Obj<Self>,
1286
+ fr: u32,
1287
+ fc: u16,
1288
+ lr: u32,
1289
+ lc: u16,
1290
+ ) -> Result<Obj<Self>, Error> {
1291
+ rb_self.with_ws(|ws| ws.set_selection(fr, fc, lr, lc).map(|_| ()).map_err(xerr))?;
1292
+ Ok(rb_self)
1293
+ }
1294
+
1295
+ fn top_left_cell(rb_self: Obj<Self>, row: u32, col: u16) -> Result<Obj<Self>, Error> {
1296
+ rb_self.with_ws(|ws| ws.set_top_left_cell(row, col).map(|_| ()).map_err(xerr))?;
1297
+ Ok(rb_self)
1298
+ }
1299
+
1300
+ #[allow(clippy::too_many_arguments)]
1301
+ fn ignore_error(
1302
+ ruby: &Ruby,
1303
+ rb_self: Obj<Self>,
1304
+ fr: u32,
1305
+ fc: u16,
1306
+ lr: u32,
1307
+ lc: u16,
1308
+ kind: Value,
1309
+ ) -> Result<Obj<Self>, Error> {
1310
+ let error = choice(ruby, "error to ignore", kind, IGNORE_ERRORS)?;
1311
+ rb_self.with_ws(|ws| {
1312
+ ws.ignore_error_range(fr, fc, lr, lc, error)
1313
+ .map(|_| ())
1314
+ .map_err(xerr)
1315
+ })?;
1316
+ Ok(rb_self)
1317
+ }
1318
+
1319
+ #[allow(clippy::too_many_arguments)]
1320
+ fn unprotect_range(
1321
+ rb_self: Obj<Self>,
1322
+ fr: u32,
1323
+ fc: u16,
1324
+ lr: u32,
1325
+ lc: u16,
1326
+ name: Option<String>,
1327
+ password: Option<String>,
1328
+ ) -> Result<Obj<Self>, Error> {
1329
+ rb_self.with_ws(|ws| {
1330
+ ws.unprotect_range_with_options(
1331
+ fr,
1332
+ fc,
1333
+ lr,
1334
+ lc,
1335
+ name.as_deref().unwrap_or(""),
1336
+ password.as_deref().unwrap_or(""),
1337
+ )
1338
+ .map(|_| ())
1339
+ .map_err(xerr)
1340
+ })?;
1341
+ Ok(rb_self)
1342
+ }
1343
+
1344
+ fn hide_columns(rb_self: Obj<Self>, first: u16, last: u16) -> Result<Obj<Self>, Error> {
1345
+ rb_self.with_ws(|ws| {
1346
+ ws.set_column_range_hidden(first, last)
1347
+ .map(|_| ())
1348
+ .map_err(xerr)
1349
+ })?;
1350
+ Ok(rb_self)
1351
+ }
1352
+
1353
+ fn set_row_format(
1354
+ ruby: &Ruby,
1355
+ rb_self: &Self,
1356
+ first: u32,
1357
+ last: u32,
1358
+ format: &Format,
1359
+ ) -> Result<(), Error> {
1360
+ rb_self.check_not_flushed(ruby, first)?;
1361
+ check_last_row(last)?;
1362
+ rb_self.with_ws(|ws| {
1363
+ for row in first..=last {
1364
+ ws.set_row_format(row, &format.0.format).map_err(xerr)?;
1365
+ }
1366
+ Ok(())
1367
+ })?;
1368
+ rb_self.row_options_set.set(true);
1369
+ rb_self
1370
+ .formatted_rows
1371
+ .borrow_mut()
1372
+ .push((first, last, format.0.clone()));
1373
+ Ok(())
1374
+ }
1375
+
1376
+ fn default_row_height(
1377
+ ruby: &Ruby,
1378
+ rb_self: Obj<Self>,
1379
+ height: f64,
1380
+ ) -> Result<Obj<Self>, Error> {
1381
+ // rust_xlsxwriter ignores 0.
1382
+ if height == 0.0 {
1383
+ return Err(Error::new(
1384
+ ruby.exception_arg_error(),
1385
+ "invalid default row height 0: use a number above 0",
1386
+ ));
1387
+ }
1388
+ check_size(ruby, "row height", height, 409.0)?;
1389
+ if rb_self.row_options_set.get() {
1390
+ return Err(Error::new(
1391
+ ruby.get_inner(&ERROR),
1392
+ "call default_row_height before row_format, hide_rows, group_rows or row_height(row, 0): rows given those keep the earlier default",
1393
+ ));
1394
+ }
1395
+ rb_self.with_ws(|ws| {
1396
+ ws.set_default_row_height(height);
1397
+ Ok(())
1398
+ })?;
1399
+ Ok(rb_self)
1400
+ }
1401
+
1111
1402
  fn set_column_format(&self, first: u16, last: u16, format: &Format) -> Result<(), Error> {
1112
1403
  self.with_ws(|ws| {
1113
- ws.set_column_range_format(first, last, &format.0)
1404
+ ws.set_column_range_format(first, last, &format.0.format)
1114
1405
  .map(|_| ())
1115
1406
  .map_err(xerr)
1116
1407
  })?;
1117
- self.formatted_columns.borrow_mut().push((first, last));
1408
+ self.formatted_columns
1409
+ .borrow_mut()
1410
+ .push((first, last, format.0.clone()));
1118
1411
  Ok(())
1119
1412
  }
1120
1413
 
@@ -1146,8 +1439,18 @@ impl Worksheet {
1146
1439
  Ok(rb_self)
1147
1440
  }
1148
1441
 
1149
- fn set_row_height(rb_self: Obj<Self>, row: u32, height: f64) -> Result<Obj<Self>, Error> {
1442
+ fn set_row_height(
1443
+ ruby: &Ruby,
1444
+ rb_self: Obj<Self>,
1445
+ row: u32,
1446
+ height: f64,
1447
+ ) -> Result<Obj<Self>, Error> {
1448
+ check_size(ruby, "row height", height, 409.0)?;
1150
1449
  rb_self.with_ws(|ws| ws.set_row_height(row, height).map(|_| ()).map_err(xerr))?;
1450
+ // A height is kept as given; only 0 (hiding the row) copies the default.
1451
+ if height == 0.0 {
1452
+ rb_self.row_options_set.set(true);
1453
+ }
1151
1454
  Ok(rb_self)
1152
1455
  }
1153
1456
 
@@ -1167,17 +1470,32 @@ impl Worksheet {
1167
1470
  })
1168
1471
  }
1169
1472
 
1170
- // Inches; a negative value keeps the current margin.
1473
+ // Inches; None keeps the current margin.
1474
+ #[allow(clippy::too_many_arguments)]
1171
1475
  fn set_margins(
1172
- &self,
1173
- left: f64,
1174
- right: f64,
1175
- top: f64,
1176
- bottom: f64,
1177
- header: f64,
1178
- footer: f64,
1476
+ ruby: &Ruby,
1477
+ rb_self: &Self,
1478
+ left: Option<f64>,
1479
+ right: Option<f64>,
1480
+ top: Option<f64>,
1481
+ bottom: Option<f64>,
1482
+ header: Option<f64>,
1483
+ footer: Option<f64>,
1179
1484
  ) -> Result<(), Error> {
1180
- self.with_ws(|ws| {
1485
+ // rust_xlsxwriter keeps a margin given as a negative number.
1486
+ let inches = |margin: Option<f64>| -> Result<f64, Error> {
1487
+ match margin {
1488
+ None => Ok(-1.0),
1489
+ Some(m) if m.is_finite() && m >= 0.0 => Ok(m),
1490
+ Some(m) => Err(Error::new(
1491
+ ruby.exception_arg_error(),
1492
+ format!("invalid margin {m}: use 0 or more inches"),
1493
+ )),
1494
+ }
1495
+ };
1496
+ let (left, right, top) = (inches(left)?, inches(right)?, inches(top)?);
1497
+ let (bottom, header, footer) = (inches(bottom)?, inches(header)?, inches(footer)?);
1498
+ rb_self.with_ws(|ws| {
1181
1499
  ws.set_margins(left, right, top, bottom, header, footer);
1182
1500
  Ok(())
1183
1501
  })
@@ -1238,8 +1556,17 @@ impl Worksheet {
1238
1556
  }
1239
1557
  let value = CellValue::from_ruby(ruby, v)?;
1240
1558
  let tables = rb_self.table_formats.borrow();
1241
- let format =
1242
- rb_self.format_for(&tables, first_row, first_col, format.map(|f| &*f.0), &value);
1559
+ let rows = rb_self.formatted_rows.borrow();
1560
+ let columns = rb_self.formatted_columns.borrow();
1561
+ let format = Self::format_for(
1562
+ &tables,
1563
+ &rows,
1564
+ &columns,
1565
+ first_row,
1566
+ first_col,
1567
+ format.map(|f| &*f.0),
1568
+ &value,
1569
+ );
1243
1570
  let default = rust_xlsxwriter::Format::new();
1244
1571
  rb_self.with_ws(|ws| {
1245
1572
  // The value was checked when converted, so once the range is
@@ -1306,7 +1633,7 @@ impl Worksheet {
1306
1633
  let mut cf =
1307
1634
  ConditionalFormatCell::new().set_rule(cell_rule(ruby, criteria, value)?);
1308
1635
  if let Some(f) = format {
1309
- cf = cf.set_format(&*f.0);
1636
+ cf = cf.set_format(&f.0.format);
1310
1637
  }
1311
1638
  rule(range, cf)
1312
1639
  }
@@ -1330,7 +1657,7 @@ impl Worksheet {
1330
1657
  };
1331
1658
  let mut cf = ConditionalFormatText::new().set_rule(text_rule);
1332
1659
  if let Some(f) = format {
1333
- cf = cf.set_format(&*f.0);
1660
+ cf = cf.set_format(&f.0.format);
1334
1661
  }
1335
1662
  rule(range, cf)
1336
1663
  }
@@ -1338,7 +1665,7 @@ impl Worksheet {
1338
1665
  let mut cf =
1339
1666
  ConditionalFormatFormula::new().set_rule(String::try_convert(value)?.as_str());
1340
1667
  if let Some(f) = format {
1341
- cf = cf.set_format(&*f.0);
1668
+ cf = cf.set_format(&f.0.format);
1342
1669
  }
1343
1670
  rule(range, cf)
1344
1671
  }
@@ -1469,9 +1796,14 @@ impl Worksheet {
1469
1796
  )?;
1470
1797
  // SAFETY: the bytes are copied into the Image before any Ruby code runs.
1471
1798
  let mut image = Image::new_from_buffer(unsafe { bytes.as_slice() }).map_err(xerr)?;
1472
- let width = opt::<f64>(ruby, options, "width")?;
1473
- let height = opt::<f64>(ruby, options, "height")?;
1474
- if let Some(scale) = opt::<f64>(ruby, options, "scale")? {
1799
+ let positive = |name: &str| -> Result<Option<f64>, Error> {
1800
+ opt::<f64>(ruby, options, name)?
1801
+ .map(|v| check_positive(ruby, name, v))
1802
+ .transpose()
1803
+ };
1804
+ let width = positive("width")?;
1805
+ let height = positive("height")?;
1806
+ if let Some(scale) = positive("scale")? {
1475
1807
  if width.is_some() || height.is_some() {
1476
1808
  return Err(Error::new(
1477
1809
  ruby.exception_arg_error(),
@@ -1680,16 +2012,7 @@ impl Worksheet {
1680
2012
  Ok(rb_self)
1681
2013
  }
1682
2014
 
1683
- fn zoom(ruby: &Ruby, rb_self: Obj<Self>, percent: Value) -> Result<Obj<Self>, Error> {
1684
- // Integers only: converting would quietly truncate 150.9.
1685
- let percent = Integer::from_value(percent)
1686
- .ok_or_else(|| {
1687
- Error::new(
1688
- ruby.exception_type_error(),
1689
- format!("zoom must be an Integer, got {}", percent.inspect()),
1690
- )
1691
- })?
1692
- .to_i64()?;
2015
+ fn zoom(ruby: &Ruby, rb_self: Obj<Self>, percent: i64) -> Result<Obj<Self>, Error> {
1693
2016
  // rust_xlsxwriter only prints a warning for these.
1694
2017
  if !(10..=400).contains(&percent) {
1695
2018
  return Err(Error::new(
@@ -1813,6 +2136,7 @@ impl Worksheet {
1813
2136
  .map(|_| ())
1814
2137
  .map_err(xerr)
1815
2138
  })?;
2139
+ rb_self.row_options_set.set(true);
1816
2140
  Ok(rb_self)
1817
2141
  }
1818
2142
 
@@ -1903,6 +2227,17 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1903
2227
  "vertical_page_breaks",
1904
2228
  method!(Worksheet::set_vertical_page_breaks, 1),
1905
2229
  )?;
2230
+ ws.define_method("_hide_rows", method!(Worksheet::hide_rows, 2))?;
2231
+ ws.define_method("_hide_columns", method!(Worksheet::hide_columns, 2))?;
2232
+ ws.define_method("_row_format", method!(Worksheet::set_row_format, 3))?;
2233
+ ws.define_method(
2234
+ "default_row_height",
2235
+ method!(Worksheet::default_row_height, 1),
2236
+ )?;
2237
+ ws.define_method("_selection", method!(Worksheet::selection, 4))?;
2238
+ ws.define_method("_top_left_cell", method!(Worksheet::top_left_cell, 2))?;
2239
+ ws.define_method("_ignore_error", method!(Worksheet::ignore_error, 5))?;
2240
+ ws.define_method("_unprotect_range", method!(Worksheet::unprotect_range, 6))?;
1906
2241
  ws.define_method("activate", method!(Worksheet::activate, 0))?;
1907
2242
  ws.define_method("hide", method!(Worksheet::hide, 0))?;
1908
2243
  ws.define_method("zoom", method!(Worksheet::zoom, 1))?;