xlsxturbo 0.6.0__tar.gz → 0.7.0__tar.gz

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.
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2025-12-28
9
+
10
+ ### Added
11
+ - **Column formatting with wildcards** - `column_formats` parameter for styling columns by pattern
12
+ - Wildcard patterns: `prefix*`, `*suffix`, `*contains*`, or exact match
13
+ - Format options: `bg_color`, `font_color`, `num_format`, `bold`, `italic`, `underline`, `border`
14
+ - Example: `column_formats={'mcpt_*': {'bg_color': '#D6EAF8', 'num_format': '0.00000', 'border': True}}`
15
+ - Available in both `df_to_xlsx()` and `dfs_to_xlsx()`
16
+ - Per-sheet column formats via options dict in `dfs_to_xlsx()`
17
+
8
18
  ## [0.6.0] - 2025-12-08
9
19
 
10
20
  ### Added
@@ -111,10 +121,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
111
121
  - Support for custom sheet names
112
122
  - Verbose mode for progress reporting
113
123
 
124
+ [0.7.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.7.0
125
+ [0.6.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.6.0
126
+ [0.5.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.5.0
114
127
  [0.4.1]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.4.1
115
128
  [0.4.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.4.0
116
129
  [0.3.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.3.0
117
130
  [0.2.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.2.0
118
131
  [0.1.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.1.0
119
- [0.5.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.5.0
120
- [0.6.0]: https://github.com/tstone-1/xlsxturbo/releases/tag/v0.6.0
@@ -90,15 +90,15 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
90
90
 
91
91
  [[package]]
92
92
  name = "bumpalo"
93
- version = "3.19.0"
93
+ version = "3.19.1"
94
94
  source = "registry+https://github.com/rust-lang/crates.io-index"
95
- checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
95
+ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
96
96
 
97
97
  [[package]]
98
98
  name = "cc"
99
- version = "1.2.49"
99
+ version = "1.2.51"
100
100
  source = "registry+https://github.com/rust-lang/crates.io-index"
101
- checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
101
+ checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
102
102
  dependencies = [
103
103
  "find-msvc-tools",
104
104
  "shlex",
@@ -282,9 +282,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
282
282
 
283
283
  [[package]]
284
284
  name = "find-msvc-tools"
285
- version = "0.1.5"
285
+ version = "0.1.6"
286
286
  source = "registry+https://github.com/rust-lang/crates.io-index"
287
- checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
287
+ checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
288
288
 
289
289
  [[package]]
290
290
  name = "flate2"
@@ -371,9 +371,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
371
371
 
372
372
  [[package]]
373
373
  name = "itoa"
374
- version = "1.0.15"
374
+ version = "1.0.17"
375
375
  source = "registry+https://github.com/rust-lang/crates.io-index"
376
- checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
376
+ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
377
377
 
378
378
  [[package]]
379
379
  name = "js-sys"
@@ -451,15 +451,15 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
451
451
 
452
452
  [[package]]
453
453
  name = "portable-atomic"
454
- version = "1.11.1"
454
+ version = "1.13.0"
455
455
  source = "registry+https://github.com/rust-lang/crates.io-index"
456
- checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
456
+ checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
457
457
 
458
458
  [[package]]
459
459
  name = "proc-macro2"
460
- version = "1.0.103"
460
+ version = "1.0.104"
461
461
  source = "registry+https://github.com/rust-lang/crates.io-index"
462
- checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
462
+ checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
463
463
  dependencies = [
464
464
  "unicode-ident",
465
465
  ]
@@ -574,9 +574,9 @@ dependencies = [
574
574
 
575
575
  [[package]]
576
576
  name = "rustix"
577
- version = "1.1.2"
577
+ version = "1.1.3"
578
578
  source = "registry+https://github.com/rust-lang/crates.io-index"
579
- checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
579
+ checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
580
580
  dependencies = [
581
581
  "bitflags",
582
582
  "errno",
@@ -593,9 +593,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
593
593
 
594
594
  [[package]]
595
595
  name = "ryu"
596
- version = "1.0.20"
596
+ version = "1.0.22"
597
597
  source = "registry+https://github.com/rust-lang/crates.io-index"
598
- checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
598
+ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
599
599
 
600
600
  [[package]]
601
601
  name = "serde_core"
@@ -654,9 +654,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
654
654
 
655
655
  [[package]]
656
656
  name = "tempfile"
657
- version = "3.23.0"
657
+ version = "3.24.0"
658
658
  source = "registry+https://github.com/rust-lang/crates.io-index"
659
- checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
659
+ checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
660
660
  dependencies = [
661
661
  "fastrand",
662
662
  "getrandom",
@@ -833,7 +833,7 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
833
833
 
834
834
  [[package]]
835
835
  name = "xlsxturbo"
836
- version = "0.6.0"
836
+ version = "0.7.0"
837
837
  dependencies = [
838
838
  "chrono",
839
839
  "clap",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "xlsxturbo"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  edition = "2021"
5
5
  description = "High-performance Excel writer with automatic type detection (pandas, polars, CSV)"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xlsxturbo
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -20,8 +20,10 @@ Features that would enable more migrations from pandas/polars write_excel.
20
20
  Power user features for more control over output.
21
21
 
22
22
  - [x] **Multi-core support** - Parallel CSV parsing with rayon (~7% speedup for large files)
23
- - [ ] **Cell formatting options** - Custom number/date formats per column
24
- - [ ] **Cell styling** - Background color, font color, bold, borders per cell/column
23
+ - [x] **Column formatting with wildcards** - `column_formats` with pattern matching (v0.7.0)
24
+ - Supports: `prefix*`, `*suffix`, `*contains*`, exact match
25
+ - Format options: bg_color, font_color, num_format, bold, italic, underline, border
26
+ - [ ] **Row-level cell formatting** - Conditional styling based on cell values
25
27
  - [ ] **Merged cells** - Merge cell ranges for headers/documentation sheets
26
28
  - [ ] **Conditional formatting** - Color scales, data bars, icon sets
27
29
  - [x] **Table styles** - Create Excel tables with auto-filters and 61 built-in styles (v0.3.0)
@@ -38,6 +40,7 @@ Niche features for specific use cases.
38
40
 
39
41
  ## Completed
40
42
 
43
+ - [x] Column formatting with wildcards via `column_formats` parameter (v0.7.0)
41
44
  - [x] Global column width cap with `column_widths={'_all': value}` (v0.6.0)
42
45
  - [x] Table name parameter with `table_name` (v0.6.0)
43
46
  - [x] Header styling with `header_format` (v0.6.0)
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "xlsxturbo"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "High-performance Excel writer with automatic type detection (pandas, polars, CSV)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -61,6 +61,7 @@ struct SheetConfig {
61
61
  table_name: Option<String>,
62
62
  header_format: Option<HashMap<String, PyObject>>,
63
63
  row_heights: Option<HashMap<u32, f64>>,
64
+ column_formats: Option<HashMap<String, HashMap<String, PyObject>>>, // Pattern -> format dict
64
65
  }
65
66
 
66
67
  /// Extract sheet info from a Python tuple (supports both 2-tuple and 3-tuple formats)
@@ -150,6 +151,26 @@ fn extract_sheet_info<'py>(
150
151
  }
151
152
  }
152
153
  }
154
+ if let Ok(val) = opts.get_item("column_formats") {
155
+ if !val.is_none() {
156
+ if let Ok(outer_dict) = val.downcast::<pyo3::types::PyDict>() {
157
+ let mut col_fmts: HashMap<String, HashMap<String, PyObject>> = HashMap::new();
158
+ for (pattern, fmt_dict) in outer_dict.iter() {
159
+ let pattern_str: String = pattern.extract()?;
160
+ if let Ok(inner_dict) = fmt_dict.downcast::<pyo3::types::PyDict>() {
161
+ let mut fmt: HashMap<String, PyObject> = HashMap::new();
162
+ for (k, v) in inner_dict.iter() {
163
+ fmt.insert(k.extract()?, v.unbind());
164
+ }
165
+ col_fmts.insert(pattern_str, fmt);
166
+ }
167
+ }
168
+ if !col_fmts.is_empty() {
169
+ config.column_formats = Some(col_fmts);
170
+ }
171
+ }
172
+ }
173
+ }
153
174
 
154
175
  config
155
176
  } else {
@@ -312,6 +333,24 @@ fn extract_header_format(
312
333
  Ok(fmt)
313
334
  }
314
335
 
336
+ /// Extract column_formats from Python dict (pattern -> format dict)
337
+ fn extract_column_formats(
338
+ py_dict: &Bound<'_, pyo3::types::PyDict>,
339
+ ) -> PyResult<HashMap<String, HashMap<String, PyObject>>> {
340
+ let mut col_fmts: HashMap<String, HashMap<String, PyObject>> = HashMap::new();
341
+ for (pattern, fmt_dict) in py_dict.iter() {
342
+ let pattern_str: String = pattern.extract()?;
343
+ if let Ok(inner_dict) = fmt_dict.downcast::<pyo3::types::PyDict>() {
344
+ let mut fmt: HashMap<String, PyObject> = HashMap::new();
345
+ for (k, v) in inner_dict.iter() {
346
+ fmt.insert(k.extract()?, v.unbind());
347
+ }
348
+ col_fmts.insert(pattern_str, fmt);
349
+ }
350
+ }
351
+ Ok(col_fmts)
352
+ }
353
+
315
354
  /// Sanitize table name for Excel (alphanumeric + underscore, must start with letter/underscore)
316
355
  fn sanitize_table_name(name: &str) -> String {
317
356
  let mut sanitized: String = name
@@ -413,6 +452,126 @@ fn parse_header_format(
413
452
  Ok(format)
414
453
  }
415
454
 
455
+ /// Check if a column name matches a wildcard pattern.
456
+ /// Supports: "prefix*", "*suffix", "*contains*", or exact match
457
+ fn matches_pattern(column_name: &str, pattern: &str) -> bool {
458
+ let starts_with_star = pattern.starts_with('*');
459
+ let ends_with_star = pattern.ends_with('*');
460
+
461
+ match (starts_with_star, ends_with_star) {
462
+ (true, true) => {
463
+ // *contains* - match substring
464
+ let inner = &pattern[1..pattern.len() - 1];
465
+ column_name.contains(inner)
466
+ }
467
+ (true, false) => {
468
+ // *suffix - match ending
469
+ let suffix = &pattern[1..];
470
+ column_name.ends_with(suffix)
471
+ }
472
+ (false, true) => {
473
+ // prefix* - match beginning
474
+ let prefix = &pattern[..pattern.len() - 1];
475
+ column_name.starts_with(prefix)
476
+ }
477
+ (false, false) => {
478
+ // Exact match
479
+ column_name == pattern
480
+ }
481
+ }
482
+ }
483
+
484
+ /// Parse column format dictionary into rust_xlsxwriter Format
485
+ /// Similar to parse_header_format but also supports num_format
486
+ fn parse_column_format(
487
+ py: Python<'_>,
488
+ fmt_dict: &HashMap<String, PyObject>,
489
+ ) -> Result<Format, String> {
490
+ let mut format = Format::new();
491
+
492
+ if let Some(bold_obj) = fmt_dict.get("bold") {
493
+ let bold: bool = bold_obj.bind(py).extract().unwrap_or(false);
494
+ if bold {
495
+ format = format.set_bold();
496
+ }
497
+ }
498
+
499
+ if let Some(italic_obj) = fmt_dict.get("italic") {
500
+ let italic: bool = italic_obj.bind(py).extract().unwrap_or(false);
501
+ if italic {
502
+ format = format.set_italic();
503
+ }
504
+ }
505
+
506
+ if let Some(bg_obj) = fmt_dict.get("bg_color") {
507
+ if let Ok(color_str) = bg_obj.bind(py).extract::<String>() {
508
+ let color = parse_color(&color_str)?;
509
+ format = format.set_background_color(color);
510
+ }
511
+ }
512
+
513
+ if let Some(font_obj) = fmt_dict.get("font_color") {
514
+ if let Ok(color_str) = font_obj.bind(py).extract::<String>() {
515
+ let color = parse_color(&color_str)?;
516
+ format = format.set_font_color(color);
517
+ }
518
+ }
519
+
520
+ if let Some(size_obj) = fmt_dict.get("font_size") {
521
+ if let Ok(size) = size_obj.bind(py).extract::<f64>() {
522
+ format = format.set_font_size(size);
523
+ }
524
+ }
525
+
526
+ if let Some(underline_obj) = fmt_dict.get("underline") {
527
+ let underline: bool = underline_obj.bind(py).extract().unwrap_or(false);
528
+ if underline {
529
+ format = format.set_underline(rust_xlsxwriter::FormatUnderline::Single);
530
+ }
531
+ }
532
+
533
+ // Support num_format for number formatting (e.g., "0.00000", "#,##0")
534
+ if let Some(num_fmt_obj) = fmt_dict.get("num_format") {
535
+ if let Ok(num_fmt_str) = num_fmt_obj.bind(py).extract::<String>() {
536
+ format = format.set_num_format(&num_fmt_str);
537
+ }
538
+ }
539
+
540
+ // Support border (adds thin border around cell)
541
+ if let Some(border_obj) = fmt_dict.get("border") {
542
+ let border: bool = border_obj.bind(py).extract().unwrap_or(false);
543
+ if border {
544
+ format = format.set_border(rust_xlsxwriter::FormatBorder::Thin);
545
+ }
546
+ }
547
+
548
+ Ok(format)
549
+ }
550
+
551
+ /// Build a vector of column formats, one for each column.
552
+ /// Returns None for columns with no matching pattern.
553
+ fn build_column_formats(
554
+ py: Python<'_>,
555
+ columns: &[String],
556
+ column_formats: &HashMap<String, HashMap<String, PyObject>>,
557
+ ) -> Result<Vec<Option<Format>>, String> {
558
+ let mut formats = Vec::with_capacity(columns.len());
559
+
560
+ for col_name in columns {
561
+ // Find the first matching pattern
562
+ let mut matched_format: Option<Format> = None;
563
+ for (pattern, fmt_dict) in column_formats {
564
+ if matches_pattern(col_name, pattern) {
565
+ matched_format = Some(parse_column_format(py, fmt_dict)?);
566
+ break;
567
+ }
568
+ }
569
+ formats.push(matched_format);
570
+ }
571
+
572
+ Ok(formats)
573
+ }
574
+
416
575
  /// Parse a string value and detect its type
417
576
  fn parse_value(value: &str) -> CellValue {
418
577
  let trimmed = value.trim();
@@ -842,6 +1001,250 @@ fn write_py_value(
842
1001
  Ok(())
843
1002
  }
844
1003
 
1004
+ /// Write a Python value to the worksheet with optional column format
1005
+ fn write_py_value_with_format(
1006
+ worksheet: &mut Worksheet,
1007
+ row: u32,
1008
+ col: u16,
1009
+ value: &Bound<'_, PyAny>,
1010
+ date_format: &Format,
1011
+ datetime_format: &Format,
1012
+ column_format: Option<&Format>,
1013
+ ) -> Result<(), String> {
1014
+ // Check for None first
1015
+ if value.is_none() {
1016
+ if let Some(fmt) = column_format {
1017
+ worksheet
1018
+ .write_string_with_format(row, col, "", fmt)
1019
+ .map_err(|e| e.to_string())?;
1020
+ } else {
1021
+ worksheet
1022
+ .write_string(row, col, "")
1023
+ .map_err(|e| e.to_string())?;
1024
+ }
1025
+ return Ok(());
1026
+ }
1027
+
1028
+ // Check for pandas NA/NaT
1029
+ let type_name = value
1030
+ .get_type()
1031
+ .name()
1032
+ .map_err(|e| e.to_string())?
1033
+ .to_string();
1034
+ if type_name == "NAType" || type_name == "NaTType" {
1035
+ if let Some(fmt) = column_format {
1036
+ worksheet
1037
+ .write_string_with_format(row, col, "", fmt)
1038
+ .map_err(|e| e.to_string())?;
1039
+ } else {
1040
+ worksheet
1041
+ .write_string(row, col, "")
1042
+ .map_err(|e| e.to_string())?;
1043
+ }
1044
+ return Ok(());
1045
+ }
1046
+
1047
+ // Try boolean first (before int, since bool is subclass of int in Python)
1048
+ if let Ok(b) = value.downcast::<PyBool>() {
1049
+ worksheet
1050
+ .write_boolean(row, col, b.is_true())
1051
+ .map_err(|e| e.to_string())?;
1052
+ return Ok(());
1053
+ }
1054
+
1055
+ // Try datetime (before date, since datetime is subclass of date)
1056
+ if type_name == "datetime" || type_name == "Timestamp" {
1057
+ let year: i32 = value
1058
+ .getattr("year")
1059
+ .ok()
1060
+ .and_then(|v| v.extract().ok())
1061
+ .unwrap_or(1900);
1062
+ let month: u32 = value
1063
+ .getattr("month")
1064
+ .ok()
1065
+ .and_then(|v| v.extract().ok())
1066
+ .unwrap_or(1);
1067
+ let day: u32 = value
1068
+ .getattr("day")
1069
+ .ok()
1070
+ .and_then(|v| v.extract().ok())
1071
+ .unwrap_or(1);
1072
+ let hour: u32 = value
1073
+ .getattr("hour")
1074
+ .ok()
1075
+ .and_then(|v| v.extract().ok())
1076
+ .unwrap_or(0);
1077
+ let minute: u32 = value
1078
+ .getattr("minute")
1079
+ .ok()
1080
+ .and_then(|v| v.extract().ok())
1081
+ .unwrap_or(0);
1082
+ let second: u32 = value
1083
+ .getattr("second")
1084
+ .ok()
1085
+ .and_then(|v| v.extract().ok())
1086
+ .unwrap_or(0);
1087
+
1088
+ if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
1089
+ if let Some(time) = chrono::NaiveTime::from_hms_opt(hour, minute, second) {
1090
+ let dt = chrono::NaiveDateTime::new(date, time);
1091
+ let excel_dt = naive_datetime_to_excel(dt);
1092
+ // For datetime, use column format if provided, otherwise datetime_format
1093
+ let fmt = column_format.unwrap_or(datetime_format);
1094
+ worksheet
1095
+ .write_number_with_format(row, col, excel_dt, fmt)
1096
+ .map_err(|e| e.to_string())?;
1097
+ return Ok(());
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ // Try date
1103
+ if type_name == "date" {
1104
+ let year: i32 = value
1105
+ .getattr("year")
1106
+ .ok()
1107
+ .and_then(|v| v.extract().ok())
1108
+ .unwrap_or(1900);
1109
+ let month: u32 = value
1110
+ .getattr("month")
1111
+ .ok()
1112
+ .and_then(|v| v.extract().ok())
1113
+ .unwrap_or(1);
1114
+ let day: u32 = value
1115
+ .getattr("day")
1116
+ .ok()
1117
+ .and_then(|v| v.extract().ok())
1118
+ .unwrap_or(1);
1119
+
1120
+ if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
1121
+ let excel_date = naive_date_to_excel(date);
1122
+ // For date, use column format if provided, otherwise date_format
1123
+ let fmt = column_format.unwrap_or(date_format);
1124
+ worksheet
1125
+ .write_number_with_format(row, col, excel_date, fmt)
1126
+ .map_err(|e| e.to_string())?;
1127
+ return Ok(());
1128
+ }
1129
+ }
1130
+
1131
+ // Try integer
1132
+ if let Ok(i) = value.downcast::<PyInt>() {
1133
+ if let Ok(val) = i.extract::<i64>() {
1134
+ if let Some(fmt) = column_format {
1135
+ worksheet
1136
+ .write_number_with_format(row, col, val as f64, fmt)
1137
+ .map_err(|e| e.to_string())?;
1138
+ } else {
1139
+ worksheet
1140
+ .write_number(row, col, val as f64)
1141
+ .map_err(|e| e.to_string())?;
1142
+ }
1143
+ return Ok(());
1144
+ }
1145
+ }
1146
+
1147
+ // Try float
1148
+ if let Ok(f) = value.downcast::<PyFloat>() {
1149
+ if let Ok(val) = f.extract::<f64>() {
1150
+ if val.is_nan() || val.is_infinite() {
1151
+ if let Some(fmt) = column_format {
1152
+ worksheet
1153
+ .write_string_with_format(row, col, "", fmt)
1154
+ .map_err(|e| e.to_string())?;
1155
+ } else {
1156
+ worksheet
1157
+ .write_string(row, col, "")
1158
+ .map_err(|e| e.to_string())?;
1159
+ }
1160
+ } else if let Some(fmt) = column_format {
1161
+ worksheet
1162
+ .write_number_with_format(row, col, val, fmt)
1163
+ .map_err(|e| e.to_string())?;
1164
+ } else {
1165
+ worksheet
1166
+ .write_number(row, col, val)
1167
+ .map_err(|e| e.to_string())?;
1168
+ }
1169
+ return Ok(());
1170
+ }
1171
+ }
1172
+
1173
+ // Try to extract as f64 (covers numpy types)
1174
+ if let Ok(val) = value.extract::<f64>() {
1175
+ if val.is_nan() || val.is_infinite() {
1176
+ if let Some(fmt) = column_format {
1177
+ worksheet
1178
+ .write_string_with_format(row, col, "", fmt)
1179
+ .map_err(|e| e.to_string())?;
1180
+ } else {
1181
+ worksheet
1182
+ .write_string(row, col, "")
1183
+ .map_err(|e| e.to_string())?;
1184
+ }
1185
+ } else if let Some(fmt) = column_format {
1186
+ worksheet
1187
+ .write_number_with_format(row, col, val, fmt)
1188
+ .map_err(|e| e.to_string())?;
1189
+ } else {
1190
+ worksheet
1191
+ .write_number(row, col, val)
1192
+ .map_err(|e| e.to_string())?;
1193
+ }
1194
+ return Ok(());
1195
+ }
1196
+
1197
+ // Try to extract as i64 (covers numpy int types)
1198
+ if let Ok(val) = value.extract::<i64>() {
1199
+ if let Some(fmt) = column_format {
1200
+ worksheet
1201
+ .write_number_with_format(row, col, val as f64, fmt)
1202
+ .map_err(|e| e.to_string())?;
1203
+ } else {
1204
+ worksheet
1205
+ .write_number(row, col, val as f64)
1206
+ .map_err(|e| e.to_string())?;
1207
+ }
1208
+ return Ok(());
1209
+ }
1210
+
1211
+ // Try to extract as bool
1212
+ if let Ok(val) = value.extract::<bool>() {
1213
+ worksheet
1214
+ .write_boolean(row, col, val)
1215
+ .map_err(|e| e.to_string())?;
1216
+ return Ok(());
1217
+ }
1218
+
1219
+ // Try string
1220
+ if let Ok(s) = value.downcast::<PyString>() {
1221
+ if let Some(fmt) = column_format {
1222
+ worksheet
1223
+ .write_string_with_format(row, col, s.to_string(), fmt)
1224
+ .map_err(|e| e.to_string())?;
1225
+ } else {
1226
+ worksheet
1227
+ .write_string(row, col, s.to_string())
1228
+ .map_err(|e| e.to_string())?;
1229
+ }
1230
+ return Ok(());
1231
+ }
1232
+
1233
+ // Fallback: convert to string
1234
+ let s = value.str().map_err(|e| e.to_string())?.to_string();
1235
+ if let Some(fmt) = column_format {
1236
+ worksheet
1237
+ .write_string_with_format(row, col, &s, fmt)
1238
+ .map_err(|e| e.to_string())?;
1239
+ } else {
1240
+ worksheet
1241
+ .write_string(row, col, &s)
1242
+ .map_err(|e| e.to_string())?;
1243
+ }
1244
+
1245
+ Ok(())
1246
+ }
1247
+
845
1248
  /// Convert a DataFrame (pandas or polars) to XLSX format
846
1249
  #[allow(clippy::too_many_arguments)]
847
1250
  fn convert_dataframe_to_xlsx(
@@ -858,6 +1261,7 @@ fn convert_dataframe_to_xlsx(
858
1261
  header_format: Option<&HashMap<String, PyObject>>,
859
1262
  row_heights: Option<&HashMap<u32, f64>>,
860
1263
  constant_memory: bool,
1264
+ column_formats: Option<&HashMap<String, HashMap<String, PyObject>>>,
861
1265
  ) -> Result<(u32, u16), String> {
862
1266
  // Create workbook and worksheet
863
1267
  let mut workbook = Workbook::new();
@@ -900,6 +1304,13 @@ fn convert_dataframe_to_xlsx(
900
1304
 
901
1305
  let col_count = columns.len() as u16;
902
1306
 
1307
+ // Build column formats if provided
1308
+ let col_formats: Vec<Option<Format>> = if let Some(cf) = column_formats {
1309
+ build_column_formats(py, &columns, cf)?
1310
+ } else {
1311
+ vec![None; columns.len()]
1312
+ };
1313
+
903
1314
  // Write header if requested (and not using table, since table handles headers)
904
1315
  if include_header && table_style.is_none() {
905
1316
  for (col_idx, col_name) in columns.iter().enumerate() {
@@ -962,13 +1373,14 @@ fn convert_dataframe_to_xlsx(
962
1373
  .map_err(|e: PyErr| e.to_string())?;
963
1374
 
964
1375
  for (col_idx, value) in row_tuple.iter().enumerate() {
965
- write_py_value(
1376
+ write_py_value_with_format(
966
1377
  worksheet,
967
1378
  row_idx,
968
1379
  col_idx as u16,
969
1380
  value,
970
1381
  &date_format,
971
1382
  &datetime_format,
1383
+ col_formats.get(col_idx).and_then(|f| f.as_ref()),
972
1384
  )?;
973
1385
  }
974
1386
  row_idx += 1;
@@ -987,13 +1399,14 @@ fn convert_dataframe_to_xlsx(
987
1399
  .get_item(col_idx)
988
1400
  .map_err(|e| format!("Failed to get value at ({}, {}): {}", i, col_idx, e))?;
989
1401
 
990
- write_py_value(
1402
+ write_py_value_with_format(
991
1403
  worksheet,
992
1404
  row_idx,
993
1405
  col_idx as u16,
994
1406
  &value,
995
1407
  &date_format,
996
1408
  &datetime_format,
1409
+ col_formats.get(col_idx).and_then(|f| f.as_ref()),
997
1410
  )?;
998
1411
  }
999
1412
  row_idx += 1;
@@ -1134,6 +1547,10 @@ fn csv_to_xlsx(
1134
1547
  /// constant_memory: Use constant memory mode for large files (default: False).
1135
1548
  /// Reduces memory usage but disables table_style, freeze_panes,
1136
1549
  /// row_heights, and autofit features.
1550
+ /// column_formats: Dict mapping column name patterns to format dicts (default: None)
1551
+ /// Supports wildcards: "prefix*", "*suffix", "*contains*", or exact match.
1552
+ /// Format options: bg_color, font_color, num_format, bold, italic, underline.
1553
+ /// Example: {"mcpt_*": {"bg_color": "#D6EAF8", "num_format": "0.00000"}}
1137
1554
  ///
1138
1555
  /// Returns:
1139
1556
  /// Tuple of (rows, columns) written to the Excel file
@@ -1154,7 +1571,7 @@ fn csv_to_xlsx(
1154
1571
  /// >>> # For very large files, use constant_memory mode:
1155
1572
  /// >>> xlsxturbo.df_to_xlsx(large_df, "big.xlsx", constant_memory=True)
1156
1573
  #[pyfunction]
1157
- #[pyo3(signature = (df, output_path, sheet_name = "Sheet1", header = true, autofit = false, table_style = None, freeze_panes = false, column_widths = None, table_name = None, header_format = None, row_heights = None, constant_memory = false))]
1574
+ #[pyo3(signature = (df, output_path, sheet_name = "Sheet1", header = true, autofit = false, table_style = None, freeze_panes = false, column_widths = None, table_name = None, header_format = None, row_heights = None, constant_memory = false, column_formats = None))]
1158
1575
  #[allow(clippy::too_many_arguments)]
1159
1576
  fn df_to_xlsx<'py>(
1160
1577
  py: Python<'py>,
@@ -1170,6 +1587,7 @@ fn df_to_xlsx<'py>(
1170
1587
  header_format: Option<&Bound<'py, PyAny>>,
1171
1588
  row_heights: Option<HashMap<u32, f64>>,
1172
1589
  constant_memory: bool,
1590
+ column_formats: Option<&Bound<'py, PyAny>>,
1173
1591
  ) -> PyResult<(u32, u16)> {
1174
1592
  // Extract column_widths if provided
1175
1593
  let extracted_column_widths = if let Some(cw) = column_widths {
@@ -1193,6 +1611,17 @@ fn df_to_xlsx<'py>(
1193
1611
  None
1194
1612
  };
1195
1613
 
1614
+ // Extract column_formats if provided
1615
+ let extracted_column_formats = if let Some(cf) = column_formats {
1616
+ if let Ok(dict) = cf.downcast::<pyo3::types::PyDict>() {
1617
+ Some(extract_column_formats(dict)?)
1618
+ } else {
1619
+ None
1620
+ }
1621
+ } else {
1622
+ None
1623
+ };
1624
+
1196
1625
  convert_dataframe_to_xlsx(
1197
1626
  py,
1198
1627
  df,
@@ -1207,6 +1636,7 @@ fn df_to_xlsx<'py>(
1207
1636
  extracted_header_format.as_ref(),
1208
1637
  row_heights.as_ref(),
1209
1638
  constant_memory,
1639
+ extracted_column_formats.as_ref(),
1210
1640
  )
1211
1641
  .map_err(pyo3::exceptions::PyValueError::new_err)
1212
1642
  }
@@ -1228,7 +1658,7 @@ fn version() -> &'static str {
1228
1658
  /// - (DataFrame, sheet_name) - uses global defaults
1229
1659
  /// - (DataFrame, sheet_name, options_dict) - per-sheet overrides
1230
1660
  /// Options dict keys: header, autofit, table_style, freeze_panes,
1231
- /// column_widths, row_heights, table_name, header_format
1661
+ /// column_widths, row_heights, table_name, header_format, column_formats
1232
1662
  /// output_path: Path for the output XLSX file
1233
1663
  /// header: Include column names as header row (default: True)
1234
1664
  /// autofit: Automatically adjust column widths to fit content (default: False)
@@ -1243,6 +1673,10 @@ fn version() -> &'static str {
1243
1673
  /// Example: {"bold": True, "bg_color": "#4F81BD", "font_color": "white"}
1244
1674
  /// row_heights: Dict mapping row index (0-based) to height in points (default: None)
1245
1675
  /// constant_memory: Use constant memory mode for large files (default: False).
1676
+ /// column_formats: Dict mapping column name patterns to format dicts (default: None)
1677
+ /// Supports wildcards: "prefix*", "*suffix", "*contains*", or exact match.
1678
+ /// Format options: bg_color, font_color, num_format, bold, italic, underline.
1679
+ /// Example: {"mcpt_*": {"bg_color": "#D6EAF8", "num_format": "0.00000"}}
1246
1680
  ///
1247
1681
  /// Returns:
1248
1682
  /// List of (rows, columns) tuples for each sheet
@@ -1265,7 +1699,7 @@ fn version() -> &'static str {
1265
1699
  /// ... (df2, "Instructions", {"header": False})
1266
1700
  /// ... ], "report.xlsx", autofit=True)
1267
1701
  #[pyfunction]
1268
- #[pyo3(signature = (sheets, output_path, header = true, autofit = false, table_style = None, freeze_panes = false, column_widths = None, table_name = None, header_format = None, row_heights = None, constant_memory = false))]
1702
+ #[pyo3(signature = (sheets, output_path, header = true, autofit = false, table_style = None, freeze_panes = false, column_widths = None, table_name = None, header_format = None, row_heights = None, constant_memory = false, column_formats = None))]
1269
1703
  #[allow(clippy::too_many_arguments)]
1270
1704
  fn dfs_to_xlsx<'py>(
1271
1705
  py: Python<'py>,
@@ -1280,6 +1714,7 @@ fn dfs_to_xlsx<'py>(
1280
1714
  header_format: Option<&Bound<'py, PyAny>>,
1281
1715
  row_heights: Option<HashMap<u32, f64>>,
1282
1716
  constant_memory: bool,
1717
+ column_formats: Option<&Bound<'py, PyAny>>,
1283
1718
  ) -> PyResult<Vec<(u32, u16)>> {
1284
1719
  let mut workbook = Workbook::new();
1285
1720
  let mut stats = Vec::new();
@@ -1306,6 +1741,17 @@ fn dfs_to_xlsx<'py>(
1306
1741
  None
1307
1742
  };
1308
1743
 
1744
+ // Extract global column_formats if provided
1745
+ let extracted_column_formats = if let Some(cf) = column_formats {
1746
+ if let Ok(dict) = cf.downcast::<pyo3::types::PyDict>() {
1747
+ Some(extract_column_formats(dict)?)
1748
+ } else {
1749
+ None
1750
+ }
1751
+ } else {
1752
+ None
1753
+ };
1754
+
1309
1755
  // Create formats
1310
1756
  let date_format = Format::new().set_num_format("yyyy-mm-dd");
1311
1757
  let datetime_format = Format::new().set_num_format("yyyy-mm-dd hh:mm:ss");
@@ -1346,6 +1792,12 @@ fn dfs_to_xlsx<'py>(
1346
1792
  global_header_fmt.clone()
1347
1793
  };
1348
1794
 
1795
+ // Get effective column formats (per-sheet or global)
1796
+ let effective_column_formats = sheet_config
1797
+ .column_formats
1798
+ .as_ref()
1799
+ .or(extracted_column_formats.as_ref());
1800
+
1349
1801
  let worksheet = if constant_memory {
1350
1802
  workbook.add_worksheet_with_constant_memory()
1351
1803
  } else {
@@ -1386,6 +1838,14 @@ fn dfs_to_xlsx<'py>(
1386
1838
 
1387
1839
  let col_count = columns.len() as u16;
1388
1840
 
1841
+ // Build column formats if provided
1842
+ let col_formats: Vec<Option<Format>> = if let Some(cf) = effective_column_formats {
1843
+ build_column_formats(py, &columns, cf)
1844
+ .map_err(pyo3::exceptions::PyValueError::new_err)?
1845
+ } else {
1846
+ vec![None; columns.len()]
1847
+ };
1848
+
1389
1849
  // Write header if requested
1390
1850
  if effective_header {
1391
1851
  for (col_idx, col_name) in columns.iter().enumerate() {
@@ -1440,13 +1900,14 @@ fn dfs_to_xlsx<'py>(
1440
1900
  .map_err(|e: PyErr| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
1441
1901
 
1442
1902
  for (col_idx, value) in row_tuple.iter().enumerate() {
1443
- write_py_value(
1903
+ write_py_value_with_format(
1444
1904
  worksheet,
1445
1905
  row_idx,
1446
1906
  col_idx as u16,
1447
1907
  value,
1448
1908
  &date_format,
1449
1909
  &datetime_format,
1910
+ col_formats.get(col_idx).and_then(|f| f.as_ref()),
1450
1911
  )
1451
1912
  .map_err(pyo3::exceptions::PyValueError::new_err)?;
1452
1913
  }
@@ -1472,13 +1933,14 @@ fn dfs_to_xlsx<'py>(
1472
1933
  ))
1473
1934
  })?;
1474
1935
 
1475
- write_py_value(
1936
+ write_py_value_with_format(
1476
1937
  worksheet,
1477
1938
  row_idx,
1478
1939
  col_idx as u16,
1479
1940
  &value,
1480
1941
  &date_format,
1481
1942
  &datetime_format,
1943
+ col_formats.get(col_idx).and_then(|f| f.as_ref()),
1482
1944
  )
1483
1945
  .map_err(pyo3::exceptions::PyValueError::new_err)?;
1484
1946
  }
@@ -1647,4 +2109,32 @@ mod tests {
1647
2109
  fn test_parse_string() {
1648
2110
  assert!(matches!(parse_value("hello"), CellValue::String(_)));
1649
2111
  }
2112
+
2113
+ #[test]
2114
+ fn test_matches_pattern_exact() {
2115
+ assert!(matches_pattern("column_name", "column_name"));
2116
+ assert!(!matches_pattern("column_name", "other"));
2117
+ }
2118
+
2119
+ #[test]
2120
+ fn test_matches_pattern_prefix() {
2121
+ assert!(matches_pattern("mcpt_weight", "mcpt_*"));
2122
+ assert!(matches_pattern("mcpt_", "mcpt_*"));
2123
+ assert!(!matches_pattern("cc_weight", "mcpt_*"));
2124
+ }
2125
+
2126
+ #[test]
2127
+ fn test_matches_pattern_suffix() {
2128
+ assert!(matches_pattern("col_weight", "*_weight"));
2129
+ assert!(matches_pattern("_weight", "*_weight"));
2130
+ assert!(!matches_pattern("col_height", "*_weight"));
2131
+ }
2132
+
2133
+ #[test]
2134
+ fn test_matches_pattern_contains() {
2135
+ assert!(matches_pattern("leadframe_difference", "*difference*"));
2136
+ assert!(matches_pattern("difference", "*difference*"));
2137
+ assert!(matches_pattern("my_difference_col", "*difference*"));
2138
+ assert!(!matches_pattern("other_column", "*difference*"));
2139
+ }
1650
2140
  }
File without changes
File without changes
File without changes
File without changes
File without changes