Skip to content

Commit d6768d8

Browse files
committed
Add configurable column widths and visibility for XLSX exports
1 parent e7a4773 commit d6768d8

9 files changed

Lines changed: 156 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ Use `WriteOptions::with_right_to_left(true)` to display a worksheet from right t
233233

234234
Enable MiniExcel v1-style fixed column sizing with `with_auto_width(true)`. Data payloads are measured without headers, bounded by `with_min_width()` and `with_max_width()` (defaults `8.42857143` and `200`), and written as fixed widths without `bestFit`. Typed rows perform one additional lightweight Serde pass when this option is enabled. Unlike .NET v1, Rust does not require a separate fast mode.
235235

236+
Set explicit per-column layout by final dynamic/Serde header name with `with_column_width()` and `with_column_hidden()`. Explicit widths are AutoWidth starting minima; hidden state does not remove data and hidden columns remain queryable.
237+
236238
Use `with_wrap_cell_contents(true)` to wrap ordinary body values. Headers, dates, times, durations, and fields with explicit number formats remain unwrapped, matching the MiniExcel v1 style boundary.
237239

238240
Configure body-cell alignment with `with_horizontal_alignment()` and `with_vertical_alignment()`. Horizontal choices are left/general, center, and right; vertical choices are bottom, center, and top. Alignment composes with wrapping and number formats but does not affect headers.

README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ AutoFilter 下拉菜单默认覆盖完整写入范围,包括只有 header 的
233233

234234
使用 `with_auto_width(true)` 可启用 MiniExcel v1 风格的固定列宽。它只测量数据 payload,不测量 header,并受 `with_min_width()``with_max_width()` 限制(默认 `8.42857143``200`);输出固定 width,不使用 `bestFit`。类型化 row 启用该选项时会额外执行一次轻量 Serde 测宽。与 .NET v1 不同,Rust 不需要单独开启 fast mode。
235235

236+
使用 `with_column_width()``with_column_hidden()` 可按最终 dynamic/Serde header name 配置每列的固定布局。Explicit width 是 AutoWidth 的起始最小值;hidden 状态不会删除数据,隐藏列仍可查询。
237+
236238
使用 `with_wrap_cell_contents(true)` 可让普通 body value 自动换行。Header、date、time、duration 及设置了 explicit number format 的字段保持不换行,与 MiniExcel v1 的 style 边界一致。
237239

238240
使用 `with_horizontal_alignment()``with_vertical_alignment()` 配置 body cell 对齐。水平方向支持 left/general、center、right;垂直方向支持 bottom、center、top。Alignment 可与换行和 number format 组合,但不影响 header。

docs/compatibility.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ The latest `calamine 0.36` and `rust_xlsxwriter 0.97` require Rust 1.88. The MVP
5252
| `AutoFilter` | `WriteOptions::with_auto_filter()` | Defaults to `true`; covers the complete written range |
5353
| `RightToLeft` | `WriteOptions::with_right_to_left()` | Defaults to `false`; changes worksheet view only |
5454
| `EnableAutoWidth` / `MinWidth` / `MaxWidth` | `WriteOptions::with_auto_width()` / `with_min_width()` / `with_max_width()` | Fixed v1-style widths; defaults to disabled, `8.42857143`, and `200` |
55+
| Per-column width/hidden | `WriteOptions::with_column_width()` / `with_column_hidden()` | Final header-name mapping; explicit width seeds AutoWidth |
5556
| `WrapCellContents` | `WriteOptions::with_wrap_cell_contents()` | Defaults to `false`; wraps ordinary body values only |
5657
| Body horizontal/vertical alignment | `WriteOptions::with_horizontal_alignment()` / `with_vertical_alignment()` | Defaults to left/general and bottom; headers are separate |
5758
| Header style | `HeaderStyle` / `WriteOptions::with_header_style()` | Blue/white/thin-border v1 visual default with configurable wrap, RGB, and alignment |
@@ -124,7 +125,7 @@ Rust integration tests reuse the repository's existing files under `tests/data/x
124125
- Forced shared-string disk spill, indexed lookup, invalid-directory handling, memory-only byte queries, and early-drop cleanup.
125126
- Structured formula text, cached values, A1 addresses, style IDs, built-in/custom number formats, ranges, and early iterator drop.
126127

127-
Writer tests generate temporary workbooks through `MiniExcel::save_as*()` and read them back through `MiniExcel::query*()`, covering dynamic and typed values, dates, multiple worksheets, visible/hidden/very-hidden states, active-sheet selection, row counts, empty schemas, default/custom/disabled freeze panes, header/headerless/typed AutoFilter ranges, right-to-left views, bounded fixed AutoWidth output, ordinary body wrapping with formatted-value exclusions, body alignment composed with wrapping and number formats, default/custom header styles, default/minimal cell style modes, explicit path overwrite behavior, and worksheet-name validation. Template tests cover scalar and mixed text, native numbers and booleans, XML escaping, formula-injection protection, missing-variable policy, empty and populated arrays, multiple sheets, style retention, path overwrite, and byte workflows. The WASM adapter has native unit tests, while Browser Lab Playwright tests cover generated-workbook rendering, query controls, inclusive end ranges, and desktop/mobile viewports.
128+
Writer tests generate temporary workbooks through `MiniExcel::save_as*()` and read them back through `MiniExcel::query*()`, covering dynamic and typed values, dates, multiple worksheets, visible/hidden/very-hidden states, active-sheet selection, row counts, empty schemas, default/custom/disabled freeze panes, header/headerless/typed AutoFilter ranges, right-to-left views, bounded fixed AutoWidth output, explicit/hidden column layout, ordinary body wrapping with formatted-value exclusions, body alignment composed with wrapping and number formats, default/custom header styles, default/minimal cell style modes, explicit path overwrite behavior, and worksheet-name validation. Template tests cover scalar and mixed text, native numbers and booleans, XML escaping, formula-injection protection, missing-variable policy, empty and populated arrays, multiple sheets, style retention, path overwrite, and byte workflows. The WASM adapter has native unit tests, while Browser Lab Playwright tests cover generated-workbook rendering, query controls, inclusive end ranges, and desktop/mobile viewports.
128129

129130
`TableStyle` controls ordinary cell formats and is not an OOXML table abstraction. Neither mode creates `xl/tables` entries or worksheet `tableParts`.
130131

docs/compatibility.zh-CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel
5252
| `AutoFilter` | `WriteOptions::with_auto_filter()` | 默认 `true`;覆盖完整写入范围 |
5353
| `RightToLeft` | `WriteOptions::with_right_to_left()` | 默认 `false`;只改变 worksheet view |
5454
| `EnableAutoWidth` / `MinWidth` / `MaxWidth` | `WriteOptions::with_auto_width()` / `with_min_width()` / `with_max_width()` | 固定 v1 风格 width;默认关闭、`8.42857143``200` |
55+
| 每列 width/hidden | `WriteOptions::with_column_width()` / `with_column_hidden()` | 按最终 header name 映射;explicit width 作为 AutoWidth 起点 |
5556
| `WrapCellContents` | `WriteOptions::with_wrap_cell_contents()` | 默认 `false`;只换行普通 body value |
5657
| Body 水平/垂直对齐 | `WriteOptions::with_horizontal_alignment()` / `with_vertical_alignment()` | 默认 left/general、bottom;header 独立 |
5758
| Header style | `HeaderStyle` / `WriteOptions::with_header_style()` | v1 蓝底白字细边框视觉默认值,可配置 wrap、RGB 和 alignment |
@@ -124,7 +125,7 @@ Rust integration test 复用仓库 `tests/data/xlsx` 下的现有文件,包括
124125
- 强制 shared-string 磁盘 spill、索引 lookup、无效目录处理、纯内存 byte query 和提前 drop 清理。
125126
- structured formula text、缓存值、A1 地址、style ID、内置/自定义 number format、range 和提前丢弃迭代器。
126127

127-
Writer test 通过 `MiniExcel::save_as*()` 生成临时 workbook,并使用 `MiniExcel::query*()` 回读,覆盖动态和类型化 value、date、多工作表、visible/hidden/very-hidden 状态、active sheet 选择、行数、空 schema、默认/自定义/禁用冻结窗格、header/headerless/typed AutoFilter 范围、从右到左 view、有界固定 AutoWidth 输出、普通 body 换行及 formatted-value 排除、body 对齐与换行/number format 组合、默认/自定义 header style、默认/最小 cell style 模式、显式 path 覆盖行为和 worksheet name 验证。模板测试覆盖标量与混合文本、原生 number/boolean、XML 转义、公式注入防护、缺失变量策略、空数组与非空数组、多工作表、样式保留、path 覆盖和 byte 工作流。WASM adapter 有原生 unit test,Browser Lab Playwright test 则覆盖生成 workbook 的渲染、query 控件、包含端点的结束 range,以及桌面/移动 viewport。
128+
Writer test 通过 `MiniExcel::save_as*()` 生成临时 workbook,并使用 `MiniExcel::query*()` 回读,覆盖动态和类型化 value、date、多工作表、visible/hidden/very-hidden 状态、active sheet 选择、行数、空 schema、默认/自定义/禁用冻结窗格、header/headerless/typed AutoFilter 范围、从右到左 view、有界固定 AutoWidth 输出、explicit/hidden column layout、普通 body 换行及 formatted-value 排除、body 对齐与换行/number format 组合、默认/自定义 header style、默认/最小 cell style 模式、显式 path 覆盖行为和 worksheet name 验证。模板测试覆盖标量与混合文本、原生 number/boolean、XML 转义、公式注入防护、缺失变量策略、空数组与非空数组、多工作表、样式保留、path 覆盖和 byte 工作流。WASM adapter 有原生 unit test,Browser Lab Playwright test 则覆盖生成 workbook 的渲染、query 控件、包含端点的结束 range,以及桌面/移动 viewport。
128129

129130
`TableStyle` 控制普通 cell format,并不是 OOXML table 抽象。两种模式都不会创建 `xl/tables` entry 或 worksheet `tableParts`
130131

docs/dotnet-feature-gaps.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Rust already implements dynamic and Serde-typed XLSX path queries, inclusive A1
3939
| CSV | Missing | Dynamic/typed CSV query and save, append, columns, DataReader/DataTable, delimiter/newline/encoding/quoting configuration, and CSV/XLSX conversion. |
4040
| Comments and notes | Missing | Retrieve threaded comments, replies, people/authors, resolution state, timestamps, and legacy notes. |
4141
| Fluent mapping | Missing | Address-based object mapping, formula/format mappings, collection start cells and spacing, nested collections, and mapped import/export/template APIs. |
42-
| Attribute-based mapping | Partial | Column index/name attributes, localized headers, width/hidden/formula metadata, custom dynamic formatters, field mapping, and dynamic column ordering/filtering. Serde covers rename, alias, defaults, skips, options, and custom serializers, but not these Excel-specific contracts. |
42+
| Attribute-based mapping | Partial | Column index/name attributes, localized headers, formula metadata, custom dynamic formatters, field mapping, and dynamic column ordering/filtering remain. Serde covers rename, alias, defaults, skips, options, and custom serializers; `WriteOptions` covers width/hidden layout by final header name. |
4343
| Read configuration | Partial | Culture-aware conversion, buffer/fast modes, and some null/empty-string behavior. Merged-cell filling and shared-string disk caching are implemented. |
4444
| Write configuration and style | Partial | OOXML tables, shared versus inline strings, and broader cell styling remain. Rust exposes default/minimal cell style modes, header output/style, AutoFilter, right-to-left views, frozen rows/columns, bounded AutoWidth, body wrapping/alignment, and number formats. |
4545
| Sheet metadata/workflow | Partial | Table metadata, comment metadata, dynamic sheet aliases, class-level sheet selection, and traversing all sheets through one reader. Rust already covers names, order, dimensions, visibility, and active state. |

docs/dotnet-feature-gaps.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Rust 已支持动态及 Serde 强类型 XLSX 路径查询、闭区间 A1 范围
3939
| CSV | 未实现 | CSV 动态/强类型查询与保存、追加、列发现、DataReader/DataTable、分隔符/换行/编码/引号配置,以及 CSV/XLSX 转换。 |
4040
| 批注与注释 | 未实现 | 读取线程化批注、回复、人员/作者、解决状态、时间戳和旧式注释。 |
4141
| Fluent Mapping | 未实现 | 基于地址的对象映射、公式/格式映射、集合起始单元格与间距、嵌套集合,以及映射式导入/导出/模板 API。 |
42-
| 特性式字段映射 | 部分实现 | 列索引/名称特性、本地化表头、宽度/隐藏/公式元数据、自定义动态格式器、字段映射,以及动态列排序/过滤。Serde 可覆盖重命名、别名、默认值、跳过、可选值和自定义序列化,但不覆盖这些 Excel 专属约定|
42+
| 特性式字段映射 | 部分实现 | 仍缺列索引/名称特性、本地化表头、公式元数据、自定义动态格式器、字段映射,以及动态列排序/过滤。Serde 可覆盖重命名、别名、默认值、跳过、可选值和自定义序列化`WriteOptions` 可按最终 header name 配置 width/hidden layout|
4343
| 读取配置 | 部分实现 | 区域文化感知转换、缓冲/快速模式,以及部分 null/空字符串行为。合并单元格填充和 shared-string 磁盘 cache 已实现。 |
4444
| 写入配置与样式 | 部分实现 | 仍缺 OOXML table、共享字符串与内联字符串选择,以及更广泛的单元格样式。Rust 已暴露默认/最小 cell style 模式、header output/style、AutoFilter、从右到左 view、冻结行列、有界 AutoWidth、body 换行/对齐和数字格式。 |
4545
| 工作表元数据与流程 | 部分实现 | 表格元数据、批注元数据、动态工作表别名、类级工作表选择,以及通过一个 reader 遍历所有工作表。Rust 已覆盖名称、顺序、尺寸、可见性和活动状态。 |

miniexcel/src/options.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,8 @@ pub struct WriteOptions {
302302
datetime_format: String,
303303
duration_format: String,
304304
column_formats: IndexMap<String, String>,
305+
column_widths: IndexMap<String, f64>,
306+
hidden_columns: IndexMap<String, bool>,
305307
sheet_visibilities: IndexMap<String, SheetVisibility>,
306308
}
307309

@@ -435,6 +437,18 @@ impl WriteOptions {
435437
self
436438
}
437439

440+
#[must_use]
441+
pub fn with_column_width(mut self, field_name: impl Into<String>, width: f64) -> Self {
442+
self.column_widths.insert(field_name.into(), width);
443+
self
444+
}
445+
446+
#[must_use]
447+
pub fn with_column_hidden(mut self, field_name: impl Into<String>, hidden: bool) -> Self {
448+
self.hidden_columns.insert(field_name.into(), hidden);
449+
self
450+
}
451+
438452
#[must_use]
439453
pub fn with_sheet_visibility(
440454
mut self,
@@ -545,6 +559,21 @@ impl WriteOptions {
545559
&self.column_formats
546560
}
547561

562+
#[must_use]
563+
pub(crate) fn column_width(&self, field_name: &str) -> Option<f64> {
564+
self.column_widths.get(field_name).copied()
565+
}
566+
567+
#[must_use]
568+
pub(crate) fn column_widths(&self) -> &IndexMap<String, f64> {
569+
&self.column_widths
570+
}
571+
572+
#[must_use]
573+
pub(crate) fn column_hidden(&self, field_name: &str) -> bool {
574+
self.hidden_columns.get(field_name).copied().unwrap_or(false)
575+
}
576+
548577
#[must_use]
549578
pub(crate) fn sheet_visibility(&self, sheet_name: &str) -> SheetVisibility {
550579
self.sheet_visibilities
@@ -582,6 +611,8 @@ impl Default for WriteOptions {
582611
datetime_format: "yyyy-mm-dd hh:mm:ss".to_owned(),
583612
duration_format: "[h]:mm:ss".to_owned(),
584613
column_formats: IndexMap::new(),
614+
column_widths: IndexMap::new(),
615+
hidden_columns: IndexMap::new(),
585616
sheet_visibilities: IndexMap::new(),
586617
}
587618
}

miniexcel/src/writer.rs

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ impl XlsxWriter {
6868
}
6969

7070
let formats = CellFormats::new(options);
71-
let mut widths = AutoWidthCollector::new(schema.len(), options)?;
71+
let mut widths = AutoWidthCollector::new(schema, options)?;
7272
for row in rows {
7373
for (column, header) in schema.iter().enumerate() {
7474
let value = row.get(header).unwrap_or(&CellValue::Empty);
@@ -79,6 +79,7 @@ impl XlsxWriter {
7979
}
8080

8181
widths.apply(&mut worksheet)?;
82+
apply_column_layout(&mut worksheet, schema, options)?;
8283

8384
if options.auto_filter() && !schema.is_empty() {
8485
worksheet.autofilter(0, 0, output_row.saturating_sub(1), schema.len() as u16 - 1)?;
@@ -122,7 +123,8 @@ impl XlsxWriter {
122123
};
123124

124125
let mut worksheet = new_worksheet(options)?;
125-
let custom_headers = serialized_field_options(first, options)?;
126+
let field_names = serialized_field_names(first)?;
127+
let custom_headers = serialized_field_options(&field_names, options);
126128
let mut header_options = SerializeFieldOptions::new()
127129
.hide_headers(!options.print_header())
128130
.set_header_format(header_format(options));
@@ -134,13 +136,14 @@ impl XlsxWriter {
134136
worksheet.serialize(row)?;
135137
}
136138

137-
let mut widths = AutoWidthCollector::new(0, options)?;
139+
let mut widths = AutoWidthCollector::new(&field_names, options)?;
138140
if options.auto_width() {
139141
for row in rows {
140142
widths.observe_serialized(row)?;
141143
}
142144
widths.apply(&mut worksheet)?;
143145
}
146+
apply_column_layout(&mut worksheet, &field_names, options)?;
144147

145148
if options.auto_filter() {
146149
let struct_name = std::any::type_name::<T>().rsplit("::").next().unwrap_or_default();
@@ -229,11 +232,14 @@ struct AutoWidthCollector {
229232
}
230233

231234
impl AutoWidthCollector {
232-
fn new(columns: usize, options: &WriteOptions) -> Result<Self> {
235+
fn new(columns: &[String], options: &WriteOptions) -> Result<Self> {
233236
validate_auto_width_options(options)?;
234237
const PADDING: f64 = 5.0 / 7.0;
235238
Ok(Self {
236-
widths: vec![options.min_width() + PADDING; columns],
239+
widths: columns
240+
.iter()
241+
.map(|column| options.column_width(column).unwrap_or(options.min_width()) + PADDING)
242+
.collect(),
237243
minimum: options.min_width() + PADDING,
238244
maximum: options.max_width() + PADDING,
239245
enabled: options.auto_width(),
@@ -456,10 +462,7 @@ fn write_cell(
456462
Ok(())
457463
}
458464

459-
fn serialized_field_options<T>(
460-
first: &T,
461-
options: &WriteOptions,
462-
) -> Result<Vec<CustomSerializeField>>
465+
fn serialized_field_names<T>(first: &T) -> Result<Vec<String>>
463466
where
464467
T: Serialize,
465468
{
@@ -469,8 +472,15 @@ where
469472
let fields = value.as_object().ok_or_else(|| {
470473
Error::invalid_write_options("typed writing requires rows serialized as structs")
471474
})?;
472-
Ok(fields
473-
.keys()
475+
Ok(fields.keys().cloned().collect())
476+
}
477+
478+
fn serialized_field_options(
479+
fields: &[String],
480+
options: &WriteOptions,
481+
) -> Vec<CustomSerializeField> {
482+
fields
483+
.iter()
474484
.map(|field_name| {
475485
let number_format = options.column_formats().get(field_name).map(String::as_str);
476486
let wrap = options.wrap_cell_contents() && number_format.is_none();
@@ -480,7 +490,38 @@ where
480490
number_format,
481491
))
482492
})
483-
.collect())
493+
.collect()
494+
}
495+
496+
fn apply_column_layout(
497+
worksheet: &mut Worksheet,
498+
columns: &[String],
499+
options: &WriteOptions,
500+
) -> Result<()> {
501+
validate_column_widths(options)?;
502+
const PADDING: f64 = 5.0 / 7.0;
503+
for (index, column) in columns.iter().enumerate() {
504+
if !options.auto_width() {
505+
if let Some(width) = options
506+
.column_width(column)
507+
.or_else(|| options.column_hidden(column).then_some(options.min_width()))
508+
{
509+
let pixels = ((width + PADDING) * 7.0).round() as u32;
510+
worksheet.set_column_width_pixels(index as u16, pixels)?;
511+
}
512+
}
513+
if options.column_hidden(column) {
514+
worksheet.set_column_hidden(index as u16)?;
515+
}
516+
}
517+
Ok(())
518+
}
519+
520+
fn validate_column_widths(options: &WriteOptions) -> Result<()> {
521+
if options.column_widths().values().any(|width| !width.is_finite() || *width < 0.0) {
522+
return Err(Error::invalid_write_options("column widths must be finite and non-negative"));
523+
}
524+
Ok(())
484525
}
485526

486527
fn validate_sheet_name(name: &str, existing_names: &HashSet<String>) -> Result<()> {

0 commit comments

Comments
 (0)