Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The latest `calamine 0.36` and `rust_xlsxwriter 0.97` require Rust 1.88. The MVP
| Typed export | `save_as_serialized<T>()` | Uses Serde mapping internally |
| Multi-sheet export | `save_as_sheets()` / `save_as_serialized_sheets()` | Preserves input sheet order and returns data-row counts |
| `InsertSheet` append/replace | `insert()` / `insert_with_schema()` / `insert_serialized()` / borrowed reader-to-writer variants | Path APIs are atomic; separate borrowed streams require an empty sink and preserve package behavior without atomic commit |
| Deferred cell font-color edits | `MiniExcel::edit_sheet()` / `WorkbookEditor::set_font_color()` / `save()` | Rust-only path API; existing cells in one worksheet, RGB colors, bounded-memory XML rewrite, and atomic commit |
| Async Insert producer | `insert_with_schema_async*()` | Optional `async` feature; bounded producer channel with blocking XLSX work on a dedicated thread |
| Async path query | `query_async*()` / `query_as_async*()` | Optional `async` feature; bounded dynamic/Serde streams, cooperative cancellation, blocking XLSX workers |
| Async dynamic/Serde export | `save_as_with_schema_async*()` / `save_as_serialized_async*()` | Optional `async` feature; explicit or first-row-inferred schema, bounded producer, atomic destination, cooperative cancellation, data-cell progress |
Expand All @@ -74,7 +75,7 @@ The latest `calamine 0.36` and `rust_xlsxwriter 0.97` require Rust 1.88. The MVP
| Caller-owned XLSX input | `visit_*_from_reader()` / metadata `*_from_reader()` | Borrowed `Read + Seek`; synchronous visitor model |
| Caller-owned XLSX output | `save_as*_to_writer()` | Borrowed `Write + Send`; dynamic, schema, typed, and multi-sheet |

`MiniExcel` is the only public behavior entry point. Reader, writer, parser, and concrete iterator types are crate-internal. Public supporting types are limited to row/cell values, structured provenance rows, options, errors/results, and Serde date/time helpers.
`MiniExcel` is the primary public behavior entry point. Reader, writer, parser, and concrete iterator types are crate-internal. `WorkbookEditor` is returned only by `MiniExcel::edit_sheet()` and exposes deferred worksheet edits. Other public supporting types are limited to row/cell values, structured provenance rows, options, errors/results, and Serde date/time helpers.

## Compatibility Defaults

Expand Down Expand Up @@ -125,7 +126,7 @@ Path RAG exports retain parser state, repeated header context, and one output ch

The backend makes two sequential, bounded-memory passes over the selected worksheet entry. The first records the used extent and compact merged-cell rectangles. This is required for MiniExcel-compatible stable dynamic schemas when legal files omit `<dimension>`, to preserve style-only row elements like the .NET reader, and to support opt-in merged-cell filling without expanding ranges into an address map. The second pass emits rows and retains only anchor values for currently active merged ranges. Worksheet XML and prior rows are never retained; memory consists primarily of in-memory or disk-indexed shared strings, styles, merge metadata, parser buffers, the current row, and the bounded channel.

The internal writer assembles a new ZIP package with one or more worksheets. Path saves refuse existing files by default and can explicitly replace them. Path Insert APIs append or replace a worksheet through a validated package rewrite and atomic sibling-file replacement; unchanged ZIP entries and existing worksheet identities are preserved. Separate borrowed Insert APIs accept `Read + Seek` input and an empty `Write + Seek` output, leave both open, and preserve the same package behavior without atomic commit, rollback, or post-write validation. Fallible explicit-schema producers are consumed once through a disk spool and a constant-memory worksheet writer. Generated donor worksheet XML, shared-string conversion, style-ID rebasing, and ZIP insertion use temporary-file streams, so worksheet memory is independent of row count. Path Insert also uses advisory locking and a pre-commit source fingerprint to prevent lost concurrent updates. Template fills rewrite worksheet XML within a copied package; worksheet styles and unrelated ZIP parts are retained. Array expansion shifts row and cell addresses and updates the worksheet dimension. Formula expressions are preserved but not recalculated, and version 1 does not adjust formula references, merged ranges, tables, drawings, or defined names after inserted rows.
The internal writer assembles a new ZIP package with one or more worksheets. Path saves refuse existing files by default and can explicitly replace them. Path Insert APIs append or replace a worksheet through a validated package rewrite and atomic sibling-file replacement; unchanged ZIP entries and existing worksheet identities are preserved. Separate borrowed Insert APIs accept `Read + Seek` input and an empty `Write + Seek` output, leave both open, and preserve the same package behavior without atomic commit, rollback, or post-write validation. Fallible explicit-schema producers are consumed once through a disk spool and a constant-memory worksheet writer. Generated donor worksheet XML, shared-string conversion, style-ID rebasing, and ZIP insertion use temporary-file streams, so worksheet memory is independent of row count. Path Insert also uses advisory locking and a pre-commit source fingerprint to prevent lost concurrent updates. `WorkbookEditor` defers font-color operations until `save()`, then sorts them by cell coordinate, lets the last operation for a cell win, scans the selected worksheet for existing style IDs, deduplicates cloned font/XF records, streams the worksheet rewrite through a temporary file, validates the package, and commits atomically. It currently edits only cells already represented in worksheet XML. Template fills rewrite worksheet XML within a copied package; worksheet styles and unrelated ZIP parts are retained. Array expansion shifts row and cell addresses and updates the worksheet dimension. Formula expressions are preserved but not recalculated, and version 1 does not adjust formula references, merged ranges, tables, drawings, or defined names after inserted rows.

## Test Sources

Expand Down
5 changes: 3 additions & 2 deletions docs/compatibility.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel
| 类型化导出 | `save_as_serialized<T>()` | 内部使用 Serde 映射 |
| 多工作表导出 | `save_as_sheets()` / `save_as_serialized_sheets()` | 保留输入工作表顺序并返回数据行数 |
| `InsertSheet` append/replace | `insert()` / `insert_with_schema()` / `insert_serialized()` / borrowed reader-to-writer variants | Path API 为原子操作;独立 borrowed stream 要求空 sink,并在无原子 commit 的情况下保持相同 package 行为 |
| 延迟 cell 字体颜色编辑 | `MiniExcel::edit_sheet()` / `WorkbookEditor::set_font_color()` / `save()` | Rust-only path API;支持单个 worksheet 中已有 cell、RGB color、有界内存 XML rewrite 与原子 commit |
| Async Insert producer | `insert_with_schema_async*()` | 可选 `async` feature;bounded producer channel,XLSX 工作在专用 blocking thread |
| Async path query | `query_async*()` / `query_as_async*()` | 可选 `async` feature;bounded 动态/Serde stream、协作式 cancellation、blocking XLSX worker |
| Async dynamic/Serde export | `save_as_with_schema_async*()` / `save_as_serialized_async*()` | 可选 `async` feature;显式或首行推断 schema、bounded producer、原子 destination、协作式 cancellation、data-cell progress |
Expand All @@ -74,7 +75,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel
| 调用方持有的 XLSX input | `visit_*_from_reader()` / metadata `*_from_reader()` | 借用 `Read + Seek`;同步 visitor 模型 |
| 调用方持有的 XLSX output | `save_as*_to_writer()` | 借用 `Write + Send`;动态、schema、类型化和多工作表 |

`MiniExcel` 是唯一公共行为入口。Reader、writer、parser 和具体迭代器类型均为 crate 内部实现。公共支持类型仅限 row/cell value、结构化 provenance row、option、error/result 和 Serde date/time helper。
`MiniExcel` 是主要公共行为入口。Reader、writer、parser 和具体迭代器类型均为 crate 内部实现。`WorkbookEditor` 仅由 `MiniExcel::edit_sheet()` 返回,用于延迟 worksheet 编辑。其他公共支持类型仅限 row/cell value、结构化 provenance row、option、error/result 和 Serde date/time helper。

## 兼容性默认值

Expand Down Expand Up @@ -125,7 +126,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel

backend 对所选 worksheet entry 执行两次顺序、有界内存扫描。第一次记录使用范围和紧凑 merged-cell 矩形。这是为了在合法文件省略 `<dimension>` 时保持 MiniExcel 兼容的稳定动态 schema、像 .NET reader 一样保留仅含 style 的 row element,并在不展开地址 map 的情况下支持按需 merged-cell 填充。第二次扫描输出 row,只保留当前活动 merge range 的锚点值。Worksheet XML 和先前 row 永远不会保留;内存主要由内存或磁盘索引的 shared string、style、merge metadata、parser buffer、当前 row 和有界 channel 构成。

内部 writer 组装包含一个或多个工作表的新 ZIP package。路径保存默认拒绝已有文件,也可显式替换。Path Insert API 通过验证后的 package rewrite 与同目录临时文件原子替换来追加或替换 worksheet;未修改的 ZIP entry 和现有 worksheet identity 会保留。独立 borrowed Insert API 接受 `Read + Seek` input 与空的 `Write + Seek` output,调用后两者保持 open,并在不提供 atomic commit、rollback 或写后验证的情况下保持相同 package 行为。可返回错误的显式 schema producer 只消费一次,经磁盘 spool 与 constant-memory worksheet writer 处理。生成的 donor worksheet XML、shared-string conversion、style-ID rebase 与 ZIP insertion 均使用临时文件 stream,因此 worksheet memory 与 row count 无关。Path Insert 还通过 advisory lock 与 commit 前 source fingerprint 防止并发更新丢失。模板填充会在复制的 package 中重写 worksheet XML;worksheet 样式和无关 ZIP part 会保留。数组展开会移动 row/cell 地址并更新 worksheet dimension。公式表达式会保留但不会重算;版本 1 不会在插行后调整公式引用、merge range、table、drawing 或 defined name。
内部 writer 组装包含一个或多个工作表的新 ZIP package。路径保存默认拒绝已有文件,也可显式替换。Path Insert API 通过验证后的 package rewrite 与同目录临时文件原子替换来追加或替换 worksheet;未修改的 ZIP entry 和现有 worksheet identity 会保留。独立 borrowed Insert API 接受 `Read + Seek` input 与空的 `Write + Seek` output,调用后两者保持 open,并在不提供 atomic commit、rollback 或写后验证的情况下保持相同 package 行为。可返回错误的显式 schema producer 只消费一次,经磁盘 spool 与 constant-memory worksheet writer 处理。生成的 donor worksheet XML、shared-string conversion、style-ID rebase 与 ZIP insertion 均使用临时文件 stream,因此 worksheet memory 与 row count 无关。Path Insert 还通过 advisory lock 与 commit 前 source fingerprint 防止并发更新丢失。`WorkbookEditor` 会把字体颜色操作延迟到 `save()`,届时按 cell 坐标排序,同一 cell 以最后登记的操作为准;随后扫描所选 worksheet 的已有 style ID、去重克隆 font/XF record、经临时文件流式重写 worksheet、验证 package 并原子提交。当前只编辑 worksheet XML 中已经存在的 cell。模板填充会在复制的 package 中重写 worksheet XML;worksheet 样式和无关 ZIP part 会保留。数组展开会移动 row/cell 地址并更新 worksheet dimension。公式表达式会保留但不会重算;版本 1 不会在插行后调整公式引用、merge range、table、drawing 或 defined name。

## 测试来源

Expand Down
2 changes: 1 addition & 1 deletion miniexcel/src/cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ impl StructuredRow {
}
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CellReference {
row: usize,
column: usize,
Expand Down
12 changes: 12 additions & 0 deletions miniexcel/src/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ use crate::{ExistingSheetPolicy, InsertOptions, SheetVisibility, TargetRelations
pub struct MiniExcel;

impl MiniExcel {
/// Starts a deferred, fluent edit for one worksheet in an existing XLSX workbook.
///
/// Operations are normalized and ordered only when [`crate::WorkbookEditor::save`] is called.
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn edit_sheet(
path: impl AsRef<Path>,
sheet_name: impl Into<String>,
) -> crate::WorkbookEditor {
crate::WorkbookEditor::new(path.as_ref().to_owned(), sheet_name.into())
}

/// Returns worksheet names in workbook order.
pub fn get_sheet_names(path: impl AsRef<Path>) -> Result<Vec<String>> {
crate::streaming::sheet_names(path)
Expand Down
64 changes: 62 additions & 2 deletions miniexcel/src/insert/atomic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ use super::package::PackageInventory;
use super::rewrite::{
PackageRewriteStage, ReplacementPlan, append_worksheet_to_writer_with_hook,
mutate_worksheet_metadata_to_writer_with_hook, plan_replacement, remap_sheet_index,
reorder_worksheet_to_writer_with_hook, replace_worksheet_to_writer_with_hook,
reorder_worksheet_to_writer_with_hook, replace_worksheet_to_writer_with_hook, styles_path,
update_font_colors_to_writer_with_hook,
};
use crate::writer::validate_sheet_name;
use crate::{Error, ExistingSheetPolicy, Result, SheetVisibility, TargetRelationshipPolicy};
use crate::{
CellReference, Error, ExistingSheetPolicy, Result, RgbColor, SheetVisibility,
TargetRelationshipPolicy,
};

const WORKSHEET_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml";
Expand Down Expand Up @@ -513,6 +517,62 @@ pub(crate) fn reorder_sheet_to_path(
reorder_sheet_to_path_with_hook(path.as_ref(), sheet_name, new_sheet_index, |_| Ok(()))
}

pub(crate) fn update_font_colors_to_path(
path: &Path,
sheet_name: &str,
colors: &std::collections::BTreeMap<CellReference, RgbColor>,
) -> Result<()> {
validate_sheet_name(sheet_name, &std::collections::HashSet::new())?;
if colors.is_empty() {
return Ok(());
}
let _guard = PathMutationGuard::acquire(path, "worksheet style update")?;
let source_fingerprint = SourceFingerprint::read(path)?;
let source_metadata = fs::metadata(path)?;
let mut source = File::open(path)?;
let inventory = PackageInventory::inspect(&mut source)?;
let target = inventory
.find_sheet(sheet_name)
.cloned()
.ok_or_else(|| Error::sheet_not_found(sheet_name))?;
let styles_path = styles_path(&inventory)?;
let parent = sibling_directory(path);
let mut temporary =
tempfile::Builder::new().prefix(".miniexcel-").suffix(".xlsx.tmp").tempfile_in(parent)?;

source.rewind()?;
update_font_colors_to_writer_with_hook(
source,
temporary.as_file_mut(),
&target.target,
&styles_path,
colors,
|_| Ok(()),
)?;
temporary.as_file_mut().flush()?;
temporary.as_file().sync_all()?;
validate_rewritten_package(temporary.reopen()?, &target.name)?;
let rewritten = PackageInventory::inspect(temporary.reopen()?)?;
if rewritten.sheets != inventory.sheets
|| rewritten.views != inventory.views
|| rewritten.defined_names != inventory.defined_names
|| rewritten.relationships != inventory.relationships
|| rewritten.entry_names != inventory.entry_names
|| rewritten.content_types != inventory.content_types
{
return Err(Error::atomic_commit(
"worksheet style update changed unrelated workbook metadata",
));
}
if SourceFingerprint::read(path)? != source_fingerprint {
return Err(Error::atomic_commit(format!(
"source workbook '{}' changed during worksheet style update",
path.display(),
)));
}
replace_temporary(temporary, path, source_metadata.permissions())
}

fn reorder_sheet_to_path_with_hook<H>(
path: &Path,
sheet_name: &str,
Expand Down
70 changes: 67 additions & 3 deletions miniexcel/src/insert/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter};

use super::donor::DonorWorksheet;
use super::package::{DefinedName, PackageInventory, WorkbookSheet, WorksheetAllocation};
use super::style::rebase_styles;
use crate::{Error, Result, SheetVisibility, TargetRelationshipPolicy};
use super::style::{
collect_cell_styles, rebase_styles, rewrite_worksheet_font_colors, update_font_colors,
};
use crate::{CellReference, Error, Result, RgbColor, SheetVisibility, TargetRelationshipPolicy};

const CONTENT_TYPES_PATH: &str = "[Content_Types].xml";
const WORKBOOK_PATH: &str = "xl/workbook.xml";
Expand Down Expand Up @@ -462,6 +464,68 @@ where
)
}

pub(super) fn update_font_colors_to_writer_with_hook<R, W, F>(
mut source: R,
destination: W,
worksheet_path: &str,
styles_path: &str,
colors: &BTreeMap<CellReference, RgbColor>,
mut checkpoint: F,
) -> Result<W>
where
R: Read + Seek,
W: Write + Seek,
F: FnMut(PackageRewriteStage) -> Result<()>,
{
source.seek(SeekFrom::Start(0))?;
let (styles_xml, cell_styles) = {
let mut archive = ZipArchive::new(&mut source).map_err(|error| {
Error::insert_package(format!("cannot reopen source workbook: {error}"))
})?;
let styles_xml = read_part(&mut archive, styles_path)?;
let worksheet = archive.by_name(worksheet_path).map_err(|error| {
Error::insert_package(format!("cannot read worksheet '{worksheet_path}': {error}"))
})?;
let cells = colors.keys().copied().collect::<BTreeSet<_>>();
let cell_styles = collect_cell_styles(BufReader::new(worksheet), &cells)?;
(styles_xml, cell_styles)
};
let (styles_xml, rewritten_styles) = update_font_colors(&styles_xml, &cell_styles, colors)?;

source.seek(SeekFrom::Start(0))?;
let mut worksheet_xml = tempfile::NamedTempFile::new()?;
{
let mut archive = ZipArchive::new(&mut source).map_err(|error| {
Error::insert_package(format!("cannot reopen source workbook: {error}"))
})?;
let worksheet = archive.by_name(worksheet_path).map_err(|error| {
Error::insert_package(format!("cannot read worksheet '{worksheet_path}': {error}"))
})?;
rewrite_worksheet_font_colors(
BufReader::new(worksheet),
worksheet_xml.as_file_mut(),
&rewritten_styles,
)?;
worksheet_xml.as_file_mut().flush()?;
}

source.seek(SeekFrom::Start(0))?;
let archive = ZipArchive::new(source).map_err(|error| {
Error::insert_package(format!("cannot reopen source workbook: {error}"))
})?;
let replacements = BTreeMap::from([(styles_path.to_owned(), styles_xml)]);
let mut worksheet_reader = BufReader::new(worksheet_xml.reopen()?);
write_package(
archive,
destination,
&replacements,
&BTreeSet::new(),
Some((worksheet_path, &mut worksheet_reader)),
None,
&mut checkpoint,
)
}

pub(super) const fn remap_sheet_index(
index: usize,
source_index: usize,
Expand Down Expand Up @@ -969,7 +1033,7 @@ fn relationship_part_path(source: &str) -> Result<String> {
}
}

fn styles_path(inventory: &PackageInventory) -> Result<String> {
pub(super) fn styles_path(inventory: &PackageInventory) -> Result<String> {
let mut paths = inventory
.relationships
.iter()
Expand Down
Loading
Loading