pub(crate) mod batch;
mod staging;
pub(crate) mod target;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, hash_map::Entry};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::{Arc, Mutex, Weak};
use ooxml_drawingml::chart::ChartSpace;
use xlsx_calc::graph::DepGraph;
use xlsx_calc::{RecalcResult, rebuild_and_recalc_all, recalc_after};
use xlsx_model::{
Border, BorderEdge, BorderStyle, CellFormat, CellRange, CellRef, CellValue, ChartAnchor, Fill,
FormatCode, FreezePane, HAlign, Hyperlink, MAX_COLS, MAX_ROWS, NumberFormat, Sheet, SheetChart,
SheetId, Stylesheet, VAlign, Workbook as WorkbookModel,
};
use xlsx_ops::{
BorderLineStyle, BorderPreset, CapturedFormat, CellState, HorizontalAlignment,
NumberFormatMutation, Op, Proposal, ProposalGhost, ProposalSet, ProposedEdit, Provenance,
StylePatch, TextWrapping, Transaction, UndoStack, VerticalAlignment,
cell_state_for_input_no_eval, insertion_keeps_chart_anchor_on_grid,
};
use xlsx_render::{
ChartRegion, DisplayList, GhostEdit, GridGeometry, PrintMetrics, RenderError, Viewport,
autofit_relevant, build_display_list_with_charts_and_ghosts,
build_print_display_list_with_charts, chart_at_point, chart_regions, display_text,
moved_chart_anchor, resolve_chart_anchor,
};
#[cfg(feature = "raster")]
use xlsx_render::{
build_display_list_with_charts, scaled, viewport_for_range, viewport_for_used_range_within,
};
use crate::authority::{
AuthorityError, HistoryUpdate, MAX_STATE_VECTOR_ENTRIES, SnapshotAdoption, StagedLocalUpdate,
StagedUpdate, SyncOrigin, WorkbookAuthority, WorkbookStructure, is_structural_op,
};
use crate::sheet_json::{
MAX_CHART_ANCHORS_PER_DRAWING, MAX_CHART_FIELD_BYTES, MAX_CHART_REFS_PER_CHART,
MAX_CHARTS_PER_SHEET, MAX_HYPERLINK_FIELD_BYTES, MAX_HYPERLINKS_PER_SHEET,
};
use crate::structured::ExportSource;
use crate::{
CalculationOptions, CalculationResult, CellAddress, CellEdit, CellInput, EditProfile,
EditStage, Error, HistoryState, MutationResult, NumberFormatKind, ProposalAcceptance,
ProposalRequest, Result, SelectionFormatting, SheetInfo, TextSearchMatch, UpdateEvent,
UpdateOrigin,
};
#[cfg(feature = "raster")]
use crate::{RenderOptions, RenderedPng};
use batch::DocumentVersion;
use staging::CommitHistory;
const MAX_RANGE_CELLS: u64 = 100_000;
pub const DEFAULT_TEXT_SEARCH_LIMIT: usize = 1_000;
const MAX_COL_WIDTH: f64 = 255.0;
const MAX_ROW_HEIGHT: f64 = 409.5;
pub const MAX_COLLABORATION_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_COLLABORATION_CLIENT_ID: u64 = (1_u64 << 53) - 1;
pub const MAX_COLLABORATION_STATE_VECTOR_ENTRIES: u32 = MAX_STATE_VECTOR_ENTRIES;
const MAX_PENDING_COLLABORATION_UPDATES: usize = 4_096;
pub const MAX_DISPLAY_CELLS: u64 = 250_000;
pub const MAX_PIXMAP_DIM: u32 = 16_384;
pub const MAX_PIXMAP_PIXELS: u64 = 16_777_216;
#[must_use = "dropping the subscription stops update delivery"]
pub struct UpdateSubscription {
observers: Weak<Mutex<UpdateObservers>>,
id: u64,
}
impl Drop for UpdateSubscription {
fn drop(&mut self) {
if let Some(observers) = self.observers.upgrade() {
observers
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.listeners
.remove(&self.id);
}
}
}
type UpdateListener = dyn Fn(UpdateEvent) + Send + Sync + 'static;
#[derive(Default)]
struct UpdateObservers {
listeners: BTreeMap<u64, Arc<UpdateListener>>,
next_id: u64,
}
enum WorkbookMode {
Standalone,
Collaborative { structure: WorkbookStructure },
}
#[derive(Clone, Default)]
struct PreservedSheetState {
origins: Vec<Option<usize>>,
shared_string_cells: Vec<xlsx_parse::SharedStringCells>,
axes: Vec<Option<xlsx_parse::SheetAxes>>,
created: Vec<bool>,
}
impl PreservedSheetState {
fn resize(&mut self, sheets: usize) {
self.origins.resize(sheets, None);
self.shared_string_cells
.resize_with(sheets, Default::default);
self.axes.resize(sheets, None);
self.created.resize(sheets, false);
}
fn insert(&mut self, index: usize) {
let index = index.min(self.origins.len());
self.origins.insert(index, None);
self.shared_string_cells
.insert(index, xlsx_parse::SharedStringCells::new());
self.axes.insert(index, None);
self.created.insert(index.min(self.created.len()), true);
}
fn remove(&mut self, index: usize) {
if index < self.origins.len() {
self.origins.remove(index);
self.shared_string_cells.remove(index);
}
if index < self.axes.len() {
self.axes.remove(index);
}
if index < self.created.len() {
self.created.remove(index);
}
}
fn shift(&mut self, sheet: SheetId, op: &Op) {
let Some(cells) = self.shared_string_cells.get_mut(sheet.0 as usize) else {
return;
};
*cells = cells
.iter()
.filter_map(|(&(row, col), &index)| {
let moved = xlsx_ops::remap_ref(CellRef::new(row, col), op)?;
Some(((moved.row, moved.col), index))
})
.collect();
if let Some(axes) = self
.axes
.get_mut(sheet.0 as usize)
.and_then(|axes| axes.as_mut())
{
match *op {
Op::InsertRows { at, count, .. } => axes.rows.insert(at, count),
Op::DeleteRows { at, count, .. } => axes.rows.delete(at, count),
Op::InsertCols { at, count, .. } => axes.cols.insert(at, count),
Op::DeleteCols { at, count, .. } => axes.cols.delete(at, count),
_ => {}
}
}
}
fn forget_shared_strings(&mut self) {
for cells in &mut self.shared_string_cells {
cells.clear();
}
}
fn forget_axes(&mut self) {
self.axes.fill(None);
}
}
#[derive(Clone)]
struct PreservedStateHistory {
before: PreservedSheetState,
after: PreservedSheetState,
}
struct SheetInfoCache {
info: SheetInfo,
bounds: Option<CellRange>,
geometry: GridGeometry,
}
impl SheetInfoCache {
fn extend_bounds(&mut self, at: CellRef, freeze_pane: Option<FreezePane>) {
let bounds = self.bounds.get_or_insert(CellRange::new(at, at));
*bounds = CellRange::new(
CellRef::new(bounds.start.row.min(at.row), bounds.start.col.min(at.col)),
CellRef::new(bounds.end.row.max(at.row), bounds.end.col.max(at.col)),
);
let content = sheet_content(self.bounds, freeze_pane, &self.geometry);
self.info.content_width = content.width;
self.info.content_height = content.height;
self.info.frozen_rows = content.frozen_rows;
self.info.frozen_cols = content.frozen_cols;
self.info.initial_scroll_x = content.initial_scroll_x;
self.info.initial_scroll_y = content.initial_scroll_y;
}
}
struct SheetContent {
width: f32,
height: f32,
frozen_rows: u32,
frozen_cols: u32,
initial_scroll_x: f32,
initial_scroll_y: f32,
}
fn sheet_content(
bounds: Option<CellRange>,
freeze_pane: Option<FreezePane>,
geometry: &GridGeometry,
) -> SheetContent {
let mut content_col = bounds
.map_or(26, |range| range.end.col.saturating_add(2))
.min(MAX_COLS);
let mut content_row = bounds
.map_or(50, |range| range.end.row.saturating_add(2))
.min(MAX_ROWS);
let (frozen_rows, frozen_cols, initial_scroll_x, initial_scroll_y) = match freeze_pane {
Some(pane) => {
content_col = content_col
.max(pane.cols.saturating_add(1))
.max(pane.top_left.col.saturating_add(2))
.min(MAX_COLS);
content_row = content_row
.max(pane.rows.saturating_add(1))
.max(pane.top_left.row.saturating_add(2))
.min(MAX_ROWS);
(
pane.rows,
pane.cols,
(geometry.col_x(pane.top_left.col) - geometry.col_x(pane.cols)).max(0.0),
(geometry.row_y(pane.top_left.row) - geometry.row_y(pane.rows)).max(0.0),
)
}
None => (0, 0, 0.0, 0.0),
};
SheetContent {
width: geometry.col_x(content_col),
height: geometry.row_y(content_row),
frozen_rows,
frozen_cols,
initial_scroll_x,
initial_scroll_y,
}
}
fn calculation_status(result: &CalculationResult) -> [BTreeSet<(u32, u32, u32)>; 2] {
let cells = |cells: &[CellAddress]| {
cells
.iter()
.map(|address| (address.sheet.0, address.cell.row, address.cell.col))
.collect()
};
[cells(&result.cycle_cells), cells(&result.limited_cells)]
}
fn on_used_edge(bounds: Option<CellRange>, at: CellRef) -> bool {
bounds.is_some_and(|bounds| {
bounds.contains(at)
&& (at.row == bounds.start.row
|| at.row == bounds.end.row
|| at.col == bounds.start.col
|| at.col == bounds.end.col)
})
}
pub struct Workbook {
authority: WorkbookAuthority,
mode: WorkbookMode,
pending_remote_updates: Vec<Vec<u8>>,
model: WorkbookModel,
source_package: Option<xlsx_parse::PreservedPackage>,
source_container: Option<ooxml_opc::SourceContainer>,
preserved: PreservedSheetState,
preserved_undo: Vec<PreservedStateHistory>,
preserved_redo: Vec<PreservedStateHistory>,
edited_since_open: bool,
moved_references_since_open: bool,
active_sheet: SheetId,
undo: UndoStack,
graph: Option<DepGraph>,
proposals: ProposalSet,
last_calculation: CalculationResult,
update_observers: Arc<Mutex<UpdateObservers>>,
opened_anchors: BTreeMap<String, ChartAnchor>,
sheet_info_cache: Mutex<Option<SheetInfoCache>>,
model_epoch: u64,
version_nonce: String,
committed_changes: u64,
chart_cache: Mutex<HashMap<(String, String), CachedChartSpace>>,
source_part_hashes: Mutex<BTreeMap<String, String>>,
}
struct CachedChartSpace {
bytes_hash: u64,
epoch: u64,
space: Arc<ChartSpace>,
}
fn chart_bytes_hash(bytes: &[u8]) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
std::hash::Hasher::write(&mut h, bytes);
std::hash::Hasher::write_usize(&mut h, bytes.len());
std::hash::Hasher::finish(&h)
}
impl Workbook {
pub fn open(bytes: &[u8]) -> Result<Self> {
Self::open_internal(bytes, true, None)
}
pub fn open_for_read(bytes: &[u8]) -> Result<Self> {
Self::open_internal(bytes, false, None)
}
pub fn open_collaborative(bytes: &[u8], client_id: u64) -> Result<Self> {
Self::open_internal(bytes, true, Some(client_id))
}
fn open_internal(bytes: &[u8], build_graph: bool, client_id: Option<u64>) -> Result<Self> {
let parts = ooxml_opc::unzip_parts(bytes).map_err(Error::Package)?;
let mut names = HashSet::with_capacity(parts.len());
for (name, _) in &parts {
if !names.insert(name) {
return Err(Error::DuplicatePart(name.clone()));
}
}
let parsed = xlsx_parse::parse_workbook_with_owned_package(parts)?;
let mut workbook = Self::from_source(
parsed.workbook,
Some(parsed.package),
parsed.active_sheet,
build_graph,
client_id,
&parsed.legacy_dimensions,
parsed.legacy_styles.as_ref(),
)?;
workbook.source_container = Some(ooxml_opc::SourceContainer::new(bytes.to_vec()));
Ok(workbook)
}
pub fn open_recalculated(bytes: &[u8], options: CalculationOptions) -> Result<Self> {
let mut workbook = Self::open_internal(bytes, false, None)?;
workbook.recalculate_all(options);
Ok(workbook)
}
pub fn open_collaborative_recalculated(
bytes: &[u8],
client_id: u64,
options: CalculationOptions,
) -> Result<Self> {
let mut workbook = Self::open_internal(bytes, false, Some(client_id))?;
workbook.recalculate_all(options);
Ok(workbook)
}
pub fn from_model(model: WorkbookModel) -> Result<Self> {
Self::from_parts(model, None, SheetId(0), true, None)
}
pub fn from_model_collaborative(model: WorkbookModel, client_id: u64) -> Result<Self> {
Self::from_parts(model, None, SheetId(0), true, Some(client_id))
}
fn from_parts(
model: WorkbookModel,
source_package: Option<xlsx_parse::PreservedPackage>,
active_sheet: SheetId,
build_graph: bool,
client_id: Option<u64>,
) -> Result<Self> {
Self::from_source(
model,
source_package,
active_sheet,
build_graph,
client_id,
&[],
None,
)
}
fn from_source(
model: WorkbookModel,
source_package: Option<xlsx_parse::PreservedPackage>,
active_sheet: SheetId,
build_graph: bool,
client_id: Option<u64>,
legacy_dimensions: &[xlsx_parse::LegacySheetDimensions],
legacy_styles: Option<&Stylesheet>,
) -> Result<Self> {
validate_model(&model)?;
validate_chart_source(&model, source_package.is_some())?;
let active_sheet = if (active_sheet.0 as usize) < model.sheets.len() {
active_sheet
} else {
SheetId(0)
};
if let Some(client_id) = client_id {
validate_collaboration_client_id(client_id)?;
}
let authority =
WorkbookAuthority::from_source(&model, client_id, legacy_dimensions, legacy_styles)
.map_err(authority_error)?;
if client_id.is_some() {
validate_collaboration_size(&authority.encode_state_as_update_v1())?;
validate_collaboration_state_entries(authority.state_vector_entries())?;
}
let mut projected = authority.materialize().map_err(authority_error)?;
retain_array_formulas(&model, &mut projected);
let model = projected;
validate_model(&model)?;
let opened_anchors = model
.sheets
.iter()
.flat_map(|sheet| &sheet.charts)
.map(|chart| (chart.frame_id(), chart.anchor))
.collect();
let graph = build_graph.then(|| DepGraph::build(&model));
let mode = match client_id {
Some(_) => WorkbookMode::Collaborative {
structure: authority.structure().map_err(authority_error)?,
},
None => WorkbookMode::Standalone,
};
let preserved = match &source_package {
Some(package) => PreservedSheetState {
origins: (0..model.sheets.len())
.map(|index| (index < package.source_sheet_count()).then_some(index))
.collect(),
shared_string_cells: (0..model.sheets.len())
.map(|index| package.source_shared_string_cells(index))
.collect(),
axes: vec![Some(xlsx_parse::SheetAxes::default()); model.sheets.len()],
created: vec![false; model.sheets.len()],
},
None => PreservedSheetState {
origins: vec![None; model.sheets.len()],
shared_string_cells: vec![Default::default(); model.sheets.len()],
axes: vec![None; model.sheets.len()],
created: vec![false; model.sheets.len()],
},
};
let version_nonce = batch::mint_nonce();
Ok(Self {
authority,
mode,
pending_remote_updates: Vec::new(),
model,
source_package,
source_container: None,
preserved,
preserved_undo: Vec::new(),
preserved_redo: Vec::new(),
edited_since_open: false,
moved_references_since_open: false,
active_sheet,
undo: UndoStack::new(),
graph,
proposals: ProposalSet::new(),
last_calculation: CalculationResult::default(),
update_observers: Arc::new(Mutex::new(UpdateObservers::default())),
opened_anchors,
sheet_info_cache: Mutex::new(None),
model_epoch: 0,
version_nonce,
committed_changes: 0,
chart_cache: Mutex::new(HashMap::new()),
source_part_hashes: Mutex::new(BTreeMap::new()),
})
}
pub fn client_id(&self) -> u64 {
self.authority.client_id()
}
pub fn version(&self) -> DocumentVersion {
DocumentVersion::new(&self.version_nonce, self.committed_changes)
}
pub fn is_collaborative(&self) -> bool {
matches!(self.mode, WorkbookMode::Collaborative { .. })
}
pub fn encode_state_vector_v1(&self) -> Vec<u8> {
self.authority.encode_state_vector_v1()
}
pub fn encode_state_as_update_v1(&self) -> Vec<u8> {
self.authority.encode_state_as_update_v1()
}
pub fn encode_diff_v1(&self, remote_state_vector: &[u8]) -> Result<Vec<u8>> {
validate_collaboration_size(remote_state_vector)?;
let update = self
.authority
.encode_diff_v1(remote_state_vector)
.map_err(authority_error)?;
validate_collaboration_size(&update)?;
Ok(update)
}
pub fn apply_update_v1(
&mut self,
update: &[u8],
options: CalculationOptions,
) -> Result<MutationResult> {
let structure = match &self.mode {
WorkbookMode::Collaborative { structure } => structure.clone(),
WorkbookMode::Standalone => return Err(Error::NotCollaborative),
};
validate_collaboration_size(update)?;
if let Some(index) = self
.pending_remote_updates
.iter()
.position(|pending| pending == update)
{
self.pending_remote_updates.remove(index);
}
let before = self.model.clone();
if self.restore_snapshot(update, options)? {
return Ok(self.remote_mutation_result(&before, true));
}
let staged = self.stage_remote_updates(&[update], None)?;
if staged.structure != structure {
return Err(Error::CollaborativeStructureChanged);
}
if staged.pending {
self.validate_pending_remote_update(update)?;
let mut applied = if staged.effective {
self.apply_staged_remote_update(staged, options)?.applied
} else {
false
};
self.pending_remote_updates.push(update.to_vec());
applied |= self.resolve_pending_remote_updates(&structure, options)?;
return Ok(self.remote_mutation_result(&before, applied));
}
let mut applied = self.apply_staged_remote_update(staged, options)?.applied;
applied |= self.resolve_pending_remote_updates(&structure, options)?;
Ok(self.remote_mutation_result(&before, applied))
}
fn restore_snapshot(&mut self, update: &[u8], options: CalculationOptions) -> Result<bool> {
let WorkbookMode::Collaborative { structure: frozen } = &self.mode else {
return Ok(false);
};
let frozen = frozen.clone();
let candidate = match self.authority.snapshot_replacement(update) {
SnapshotAdoption::NotApplicable => return Ok(false),
SnapshotAdoption::Incompatible(error) => return Err(Error::CollaborativeState(error)),
SnapshotAdoption::Replacement(candidate) => *candidate,
};
let structure = candidate.structure().map_err(authority_error)?;
if !structure.describes_same_workbook(&frozen) {
return Err(Error::CollaborativeStructureChanged);
}
let mut model = candidate.materialize().map_err(authority_error)?;
retain_array_formulas(&self.model, &mut model);
self.gate_incoming(&model)
.map_err(|error| Error::CollaborativeState(error.to_string()))?;
let migrated = candidate.encode_state_as_update_v1();
validate_collaboration_state(migrated.len(), candidate.state_vector_entries())?;
let (graph, recalc) = rebuild_and_recalc_all(&mut model, options.now_serial);
let mut calculation = calculation_result(&recalc);
calculation.changed = changed_cells_between(&self.model, &model);
self.authority = candidate;
self.preserved.resize(model.sheets.len());
self.install_model(model)?;
self.invalidate_sheet_info();
self.graph = Some(graph);
self.last_calculation = calculation;
self.mode = WorkbookMode::Collaborative { structure };
self.pending_remote_updates.clear();
self.undo.clear();
self.preserved_undo.clear();
self.preserved_redo.clear();
self.preserved.forget_shared_strings();
self.preserved.forget_axes();
self.authority.clear_history();
self.proposals.clear();
self.edited_since_open = true;
self.version_nonce = batch::mint_nonce();
self.emit_update(UpdateEvent {
update: migrated,
origin: UpdateOrigin::Local,
});
Ok(true)
}
fn stage_remote_updates(
&self,
updates: &[&[u8]],
baseline: Option<&[u8]>,
) -> Result<StagedUpdate> {
let staged = self
.authority
.stage_updates_v1(updates, baseline)
.map_err(authority_error)?;
validate_collaboration_state(staged.state_bytes, staged.state_vector_entries)?;
self.gate_incoming(&staged.model)
.map_err(|error| Error::CollaborativeState(error.to_string()))?;
Ok(staged)
}
fn gate_incoming(&self, model: &WorkbookModel) -> Result<()> {
validate_model(model)?;
validate_chart_source(model, self.source_package.is_some())?;
self.validate_incoming_anchors(model)
}
fn validate_incoming_anchors(&self, staged: &WorkbookModel) -> Result<()> {
for sheet in &staged.sheets {
for chart in &sheet.charts {
if self.opened_anchors.get(&chart.frame_id()) == Some(&chart.anchor) {
continue;
}
validate_intrinsic_anchor(chart.anchor).map_err(|error| {
Error::InvalidOperation(format!("remote update repins {}: {error}", chart.part))
})?;
}
}
Ok(())
}
fn resolve_pending_remote_updates(
&mut self,
structure: &WorkbookStructure,
options: CalculationOptions,
) -> Result<bool> {
let mut applied = false;
let mut index = 0;
let mut baseline = None;
while index < self.pending_remote_updates.len() {
let update = self.pending_remote_updates[index].clone();
let staged = self.stage_remote_updates(
&[&update],
Some(baseline.get_or_insert_with(|| self.authority.encode_state_as_update_v1())),
);
match staged {
Ok(staged) if &staged.structure != structure => {
self.pending_remote_updates.remove(index);
}
Ok(staged) if staged.pending => {
if staged.effective {
applied |= self.apply_staged_remote_update(staged, options)?.applied;
baseline = None;
index = 0;
} else {
index += 1;
}
}
Ok(staged) => {
self.pending_remote_updates.remove(index);
applied |= self.apply_staged_remote_update(staged, options)?.applied;
baseline = None;
index = 0;
}
Err(_) => {
self.pending_remote_updates.remove(index);
}
}
}
Ok(applied)
}
fn validate_pending_remote_update(&self, update: &[u8]) -> Result<()> {
let updates = self.pending_remote_updates.len() + 1;
if updates > MAX_PENDING_COLLABORATION_UPDATES {
return Err(Error::CollaborationPendingUpdatesTooMany {
updates,
max: MAX_PENDING_COLLABORATION_UPDATES,
});
}
let bytes = self
.pending_remote_updates
.iter()
.try_fold(update.len(), |total, pending| {
total.checked_add(pending.len())
})
.unwrap_or(usize::MAX);
if bytes > MAX_COLLABORATION_BYTES {
return Err(Error::CollaborationDataTooLarge {
bytes,
max: MAX_COLLABORATION_BYTES,
});
}
Ok(())
}
fn apply_staged_remote_update(
&mut self,
staged: StagedUpdate,
options: CalculationOptions,
) -> Result<MutationResult> {
if !staged.effective {
return Ok(MutationResult::default());
}
let commit_update = staged.commit_update;
let mut model = staged.model;
retain_array_formulas(&self.model, &mut model);
let update = staged.update;
let (graph, recalc) = rebuild_and_recalc_all(&mut model, options.now_serial);
let mut calculation = calculation_result(&recalc);
calculation.changed = changed_cells_between(&self.model, &model);
self.authority
.apply_staged_update_v1(&commit_update)
.map_err(authority_error)?;
self.install_model(model)?;
self.invalidate_sheet_info();
self.graph = Some(graph);
self.last_calculation = calculation.clone();
self.undo.clear();
self.preserved_undo.clear();
self.preserved_redo.clear();
self.preserved.forget_shared_strings();
self.preserved.forget_axes();
self.authority.clear_history();
self.edited_since_open = true;
self.committed_changes += 1;
self.emit_update(UpdateEvent {
update,
origin: UpdateOrigin::Remote,
});
Ok(MutationResult {
applied: true,
changed: calculation.changed,
cycle_cells: calculation.cycle_cells,
limited_cells: calculation.limited_cells,
})
}
fn remote_mutation_result(&self, before: &WorkbookModel, applied: bool) -> MutationResult {
if !applied {
return MutationResult::default();
}
MutationResult {
applied: true,
changed: changed_cells_between(before, &self.model),
cycle_cells: self.last_calculation.cycle_cells.clone(),
limited_cells: self.last_calculation.limited_cells.clone(),
}
}
pub fn observe_update_v1<F>(&self, callback: F) -> Result<UpdateSubscription>
where
F: Fn(UpdateEvent) + Send + Sync + 'static,
{
let mut observers = self
.update_observers
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let id = observers.next_id;
observers.next_id = observers
.next_id
.checked_add(1)
.ok_or_else(|| Error::CollaborativeState("update observer ID overflow".to_string()))?;
observers.listeners.insert(id, Arc::new(callback));
Ok(UpdateSubscription {
observers: Arc::downgrade(&self.update_observers),
id,
})
}
fn rezip<S: AsRef<[u8]>>(&self, parts: &[(String, S)]) -> Result<Vec<u8>> {
match &self.source_container {
Some(source) => ooxml_opc::rezip_parts_preserving(parts, source.as_bytes()),
None => ooxml_opc::rezip_parts_borrowed(parts),
}
.map_err(Error::Package)
}
pub fn save(&self) -> Result<Vec<u8>> {
validate_model(&self.model)?;
validate_chart_source(&self.model, self.source_package.is_some())?;
match &self.source_package {
Some(package) => {
let parts = xlsx_parse::serialize_workbook_with_package_and_origins_after_edits_and_active_sheet_with_axes(
&self.model,
package,
&self.preserved.origins,
&self.preserved.shared_string_cells,
&self.preserved.axes,
xlsx_parse::SaveEdits {
changed: self.edited_since_open,
moved_references: self.moved_references_since_open,
},
self.active_sheet,
)?;
self.rezip(&parts)
}
None => self.rezip(&xlsx_parse::serialize_workbook_with_active_sheet(
&self.model,
self.active_sheet,
)?),
}
}
pub fn model(&self) -> &WorkbookModel {
&self.model
}
fn has_uncached_source_formulas(&self) -> bool {
self.source_package.as_ref().is_some_and(|package| {
(0..package.source_sheet_count()).any(|index| {
package
.source_cell_facts(index)
.is_some_and(|facts| !facts.uncached_formulas.is_empty())
})
})
}
pub(crate) fn export_source(&self) -> ExportSource<'_> {
ExportSource {
model: &self.model,
package: self.source_package.as_ref(),
origins: &self.preserved.origins,
shared_string_cells: &self.preserved.shared_string_cells,
axes: &self.preserved.axes,
calculation: &self.last_calculation,
created: &self.preserved.created,
edited: self.edited_since_open,
part_hashes: &self.source_part_hashes,
sheet_ids: self.sheet_keys(),
}
}
pub fn into_model(self) -> WorkbookModel {
self.model
}
pub fn sheet(&self, sheet: SheetId) -> Result<&Sheet> {
self.model.sheet(sheet).ok_or(Error::SheetOutOfRange(sheet))
}
pub fn sheet_count(&self) -> usize {
self.model.sheets.len()
}
pub fn sheet_id(&self, name: &str) -> Option<SheetId> {
self.model.sheet_by_name(name).map(|(id, _)| id)
}
pub fn active_sheet(&self) -> SheetId {
self.active_sheet
}
pub fn set_active_sheet(&mut self, sheet: SheetId) -> Result<()> {
self.sheet(sheet)?;
self.active_sheet = sheet;
self.invalidate_sheet_info();
Ok(())
}
pub fn sheet_info(&self) -> Result<SheetInfo> {
let mut slot = self
.sheet_info_cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(cached) = &*slot {
return Ok(cached.info.clone());
}
let sheet = self.sheet(self.active_sheet)?;
let geometry = GridGeometry::new(sheet, &self.model.styles);
let bounds = sheet.used_range();
let content = sheet_content(bounds, sheet.freeze_pane, &geometry);
let info = SheetInfo {
sheet_ids: self.sheet_keys(),
sheet_names: self
.model
.sheets
.iter()
.map(|sheet| sheet.name.clone())
.collect(),
active_sheet: self.active_sheet,
content_width: content.width,
content_height: content.height,
frozen_rows: content.frozen_rows,
frozen_cols: content.frozen_cols,
initial_scroll_x: content.initial_scroll_x,
initial_scroll_y: content.initial_scroll_y,
};
*slot = Some(SheetInfoCache {
info: info.clone(),
bounds,
geometry,
});
Ok(info)
}
pub fn cell_scroll_position(&self, sheet: SheetId, cell: CellRef) -> Result<(f32, f32)> {
validate_cell_ref(cell)?;
let sheet = self.sheet(sheet)?;
let geometry = GridGeometry::new(sheet, &self.model.styles);
let (frozen_rows, frozen_cols) = sheet
.freeze_pane
.map_or((0, 0), |pane| (pane.rows, pane.cols));
Ok((
(geometry.col_x(cell.col) - geometry.col_x(frozen_cols)).max(0.0),
(geometry.row_y(cell.row) - geometry.row_y(frozen_rows)).max(0.0),
))
}
pub fn cell(&self, sheet: SheetId, cell: CellRef) -> Result<CellEdit> {
self.validate_cell(cell)?;
let sheet_ref = self.sheet(sheet)?;
let (input, is_formula) = match sheet_ref.cell(cell) {
Some(cell) => match &cell.formula {
Some(formula) => (format!("={formula}"), true),
None => (value_to_input(&cell.value), false),
},
None => (String::new(), false),
};
Ok(CellEdit {
cell,
input,
is_formula,
})
}
pub fn search_text(
&self,
query: &str,
case_sensitive: bool,
limit: Option<usize>,
) -> Vec<TextSearchMatch> {
if query.is_empty() || limit == Some(0) {
return Vec::new();
}
let limit = limit.unwrap_or(DEFAULT_TEXT_SEARCH_LIMIT);
let folded_query = (!case_sensitive).then(|| query.to_lowercase());
let mut matches = Vec::new();
for (sheet_index, sheet) in self.model.sheets.iter().enumerate() {
for (cell_ref, cell) in sheet.iter_cells() {
let text = display_text(&self.model.styles, self.model.date_system, cell);
let found = match &folded_query {
Some(needle) => contains_lowercased(&text, needle),
None => text.contains(query),
};
if !found {
continue;
}
matches.push(TextSearchMatch {
address: CellAddress {
sheet: SheetId(sheet_index as u32),
cell: cell_ref,
},
text,
});
if matches.len() == limit {
return matches;
}
}
}
matches
}
pub fn range_cells(&self, sheet: SheetId, range: CellRange) -> Result<Vec<Vec<CellEdit>>> {
let (rows, cols) = self.validate_bounded_range(sheet, range)?;
let mut cells = Vec::with_capacity(rows as usize);
for row in range.start.row..=range.end.row {
let mut row_cells = Vec::with_capacity(cols as usize);
for col in range.start.col..=range.end.col {
row_cells.push(self.cell(sheet, CellRef::new(row, col))?);
}
cells.push(row_cells);
}
Ok(cells)
}
pub fn patch_range_style(
&mut self,
sheet: SheetId,
range: CellRange,
patch: StylePatch,
options: CalculationOptions,
) -> Result<MutationResult> {
self.validate_bounded_range(sheet, range)?;
self.apply_ops(
vec![Op::PatchRangeStyle {
sheet,
range,
patch,
}],
options,
)
}
pub fn set_range_number_format(
&mut self,
sheet: SheetId,
range: CellRange,
format: NumberFormatMutation,
options: CalculationOptions,
) -> Result<MutationResult> {
self.validate_bounded_range(sheet, range)?;
self.apply_ops(
vec![Op::SetRangeNumberFormat {
sheet,
range,
format,
}],
options,
)
}
pub fn selection_formatting(
&self,
sheet: SheetId,
range: CellRange,
) -> Result<SelectionFormatting> {
self.validate_bounded_range(sheet, range)?;
let formats = self.range_formats(sheet, range)?;
let number_formats = formats
.iter()
.map(|(_, format)| number_format_kind(&format.number_format))
.collect::<Vec<_>>();
let number_format = uniform(number_formats.iter().map(|(kind, _)| *kind));
let number_format_pattern =
uniform(number_formats.iter().map(|(_, pattern)| pattern.clone())).flatten();
let styles = &self.model.styles;
Ok(SelectionFormatting {
number_format,
number_format_pattern,
font_family: uniform(
formats.iter().map(|(_, format)| {
format.font.name.clone().unwrap_or_else(|| "Calibri".into())
}),
),
font_size: uniform(
formats
.iter()
.map(|(_, format)| format.font.size_pt.unwrap_or(11.0)),
),
bold: uniform(formats.iter().map(|(_, format)| format.font.bold)),
italic: uniform(formats.iter().map(|(_, format)| format.font.italic)),
strikethrough: uniform(formats.iter().map(|(_, format)| format.font.strike)),
text_color: uniform(formats.iter().map(|(_, format)| {
format
.font
.color
.as_ref()
.and_then(|color| styles.resolve_color(color))
.unwrap_or_else(|| "#000000".into())
.to_ascii_lowercase()
})),
fill_color: uniform(formats.iter().map(|(_, format)| {
match &format.fill {
Fill::Solid(color) => styles
.resolve_color(color)
.unwrap_or_else(|| "#ffffff".into())
.to_ascii_lowercase(),
Fill::None => "#ffffff".into(),
}
})),
border_preset: detect_border_preset(&formats, range),
border_style: uniform_border_value(&formats, |edge| border_line_style(edge.style)),
border_color: uniform_border_value(&formats, |edge| {
edge.color
.as_ref()
.and_then(|color| styles.resolve_color(color))
.unwrap_or_else(|| "#000000".into())
.to_ascii_lowercase()
}),
horizontal_alignment: uniform(formats.iter().map(
|(_, format)| match format.alignment.h {
Some(HAlign::Center | HAlign::CenterContinuous | HAlign::Distributed) => {
HorizontalAlignment::Center
}
Some(HAlign::Right) => HorizontalAlignment::Right,
_ => HorizontalAlignment::Left,
},
)),
vertical_alignment: uniform(formats.iter().map(
|(_, format)| match format.alignment.v {
Some(VAlign::Top) => VerticalAlignment::Top,
Some(VAlign::Center | VAlign::Distributed | VAlign::Justify) => {
VerticalAlignment::Middle
}
_ => VerticalAlignment::Bottom,
},
)),
text_wrapping: uniform(formats.iter().map(|(_, format)| {
if format.alignment.wrap_text {
TextWrapping::Wrap
} else if format.alignment.shrink_to_fit {
TextWrapping::Clip
} else {
TextWrapping::Overflow
}
})),
})
}
pub fn capture_format(&self, sheet: SheetId, range: CellRange) -> Result<CapturedFormat> {
let (rows, columns) = self.validate_bounded_range(sheet, range)?;
Ok(CapturedFormat {
rows: rows as u32,
columns: columns as u32,
formats: self
.range_formats(sheet, range)?
.into_iter()
.map(|(_, format)| format)
.collect(),
})
}
pub fn apply_format(
&mut self,
sheet: SheetId,
range: CellRange,
format: CapturedFormat,
options: CalculationOptions,
) -> Result<MutationResult> {
self.validate_bounded_range(sheet, range)?;
self.apply_ops(
vec![Op::ApplyRangeFormat {
sheet,
range,
format,
}],
options,
)
}
pub fn merged_ranges(&self, sheet: SheetId, range: CellRange) -> Result<Vec<CellRange>> {
validate_range(range)?;
Ok(self
.sheet(sheet)?
.merges
.iter()
.copied()
.filter(|merged| ranges_intersect(*merged, range))
.collect())
}
pub fn edit_cell(
&mut self,
sheet: SheetId,
cell: CellRef,
input: &str,
options: CalculationOptions,
) -> Result<MutationResult> {
self.edit_cell_marked(sheet, cell, input, options, &mut |_| {})
}
pub fn edit_cell_profiled(
&mut self,
sheet: SheetId,
cell: CellRef,
input: &str,
options: CalculationOptions,
now: &mut impl FnMut() -> f64,
) -> Result<(MutationResult, EditProfile)> {
profiled(now, |mark| {
self.edit_cell_marked(sheet, cell, input, options, mark)
})
}
fn edit_cell_marked(
&mut self,
sheet: SheetId,
cell: CellRef,
input: &str,
options: CalculationOptions,
mark: &mut dyn FnMut(EditStage),
) -> Result<MutationResult> {
self.validate_target(sheet, cell)?;
let state = edit_cell_state(&self.model, sheet, cell, input);
validate_cell_state(&state)?;
if cell_states_semantically_equal(¤t_cell_state(&self.model, sheet, cell), &state) {
return Ok(MutationResult::default());
}
mark(EditStage::Validated);
self.ensure_graph();
let formula = state.formula.clone();
let ops = vec![Op::SetCell {
sheet,
at: cell,
cell: state,
}];
let update = self.commit_user(&ops)?;
self.graph.as_mut().expect("graph initialized").set_formula(
sheet,
cell,
formula.as_deref(),
);
mark(EditStage::Applied);
let seeds = [(sheet, cell)];
let result = recalc_after(
&mut self.model,
self.graph.as_mut().expect("graph initialized"),
&seeds,
options.now_serial,
);
mark(EditStage::Recalculated);
let result = self.mutation_result(true, result, &seeds);
self.publish(update);
Ok(result)
}
pub fn edit_cells(
&mut self,
sheet: SheetId,
edits: &[CellInput],
options: CalculationOptions,
) -> Result<MutationResult> {
if edits.is_empty() {
return Ok(MutationResult::default());
}
self.ensure_worksheet_sheet(sheet)?;
let mut touched = Vec::with_capacity(edits.len());
let mut ops = Vec::with_capacity(edits.len());
let mut preview = self.model.clone();
let mut per_op = Vec::with_capacity(edits.len());
for edit in edits {
self.validate_cell(edit.cell)?;
let state = edit_cell_state(&preview, sheet, edit.cell, &edit.input);
validate_cell_state(&state)?;
let old = current_cell_state(&preview, sheet, edit.cell);
if cell_states_semantically_equal(&old, &state) {
continue;
}
preview
.sheet_mut(sheet)
.expect("sheet validated")
.set_cell(edit.cell, state.clone().into());
touched.push((sheet, edit.cell, state.formula.clone()));
per_op.push(vec![Op::SetCell {
sheet,
at: edit.cell,
cell: old,
}]);
ops.push(Op::SetCell {
sheet,
at: edit.cell,
cell: state,
});
}
if ops.is_empty() || models_semantically_equal(&preview, &self.model) {
return Ok(MutationResult::default());
}
let mut inverse = Vec::new();
for chunk in per_op.into_iter().rev() {
inverse.extend(chunk);
}
self.ensure_graph();
let prepared = self.prepare_commit(
ops,
StagedApply::new(preview, inverse),
SyncOrigin::User,
CommitHistory::Separate,
None,
)?;
let update = self.commit_prepared(prepared)?;
for (sheet, cell, formula) in &touched {
self.graph.as_mut().expect("graph initialized").set_formula(
*sheet,
*cell,
formula.as_deref(),
);
}
let seeds: Vec<_> = touched
.iter()
.map(|(sheet, cell, _)| (*sheet, *cell))
.collect();
let result = recalc_after(
&mut self.model,
self.graph.as_mut().expect("graph initialized"),
&seeds,
options.now_serial,
);
let result = self.mutation_result(true, result, &seeds);
self.publish(update);
Ok(result)
}
pub fn apply_ops(
&mut self,
ops: Vec<Op>,
options: CalculationOptions,
) -> Result<MutationResult> {
self.apply_ops_marked(ops, options, &mut |_| {})
}
pub fn apply_ops_profiled(
&mut self,
ops: Vec<Op>,
options: CalculationOptions,
now: &mut impl FnMut() -> f64,
) -> Result<(MutationResult, EditProfile)> {
profiled(now, |mark| self.apply_ops_marked(ops, options, mark))
}
fn apply_ops_marked(
&mut self,
ops: Vec<Op>,
options: CalculationOptions,
mark: &mut dyn FnMut(EditStage),
) -> Result<MutationResult> {
if ops.is_empty() {
return Ok(MutationResult::default());
}
if self.is_collaborative() && ops.iter().any(is_structural_op) {
return Err(Error::CollaborativeStructureOperation);
}
let invalidates_proposals = ops.iter().any(invalidates_proposals);
let mut preview = self.model.clone();
let mut names = self.sheet_names();
let mut per_op = Vec::with_capacity(ops.len());
for op in &ops {
if let Some(sheet) = worksheet_edit_target(op) {
self.ensure_worksheet_sheet(sheet)?;
}
self.ensure_references_stay_valid(&names, op)?;
validate_op(&preview, op)?;
validate_insert_capacity(&preview, op)?;
per_op.push(xlsx_ops::apply_in_place(&mut preview, op)?.0);
rename_sheet_view(&mut names, op);
}
validate_model_sheets(&preview)?;
validate_shared_drawings(&preview)?;
if preview == self.model {
return Ok(MutationResult::default());
}
mark(EditStage::Validated);
let mut inverse = Vec::new();
for chunk in per_op.into_iter().rev() {
inverse.extend(chunk);
}
let active_name = self.active_sheet_name();
let prepared = self.prepare_commit(
ops,
StagedApply::new(preview, inverse),
SyncOrigin::User,
CommitHistory::Separate,
None,
)?;
let update = self.commit_prepared(prepared)?;
self.restore_active_sheet(active_name.as_deref());
if invalidates_proposals {
self.proposals.clear();
}
mark(EditStage::Applied);
let result = self.rebuild_and_recalculate(options);
mark(EditStage::Recalculated);
self.publish(update);
Ok(MutationResult {
applied: true,
changed: result.changed,
cycle_cells: result.cycle_cells,
limited_cells: result.limited_cells,
})
}
pub fn recalculate_all(&mut self, options: CalculationOptions) -> CalculationResult {
let first = !self.edited_since_open && self.has_uncached_source_formulas();
let before = calculation_status(&self.last_calculation);
let result = self.rebuild_and_recalculate(options);
if !result.changed.is_empty() || first || calculation_status(&result) != before {
self.committed_changes += 1;
self.emit_update(UpdateEvent {
update: Vec::new(),
origin: UpdateOrigin::Recalculation,
});
}
result
}
pub fn can_undo(&self) -> bool {
if self.is_collaborative() {
self.authority.can_undo()
} else {
self.undo.can_undo()
}
}
pub fn can_redo(&self) -> bool {
if self.is_collaborative() {
self.authority.can_redo()
} else {
self.undo.can_redo()
}
}
pub fn history_state(&self) -> HistoryState {
if self.is_collaborative() {
return HistoryState {
can_undo: self.authority.can_undo(),
can_redo: self.authority.can_redo(),
undo_depth: self.authority.undo_depth(),
redo_depth: self.authority.redo_depth(),
};
}
HistoryState {
can_undo: self.undo.can_undo(),
can_redo: self.undo.can_redo(),
undo_depth: self.undo.undo_depth(),
redo_depth: self.undo.redo_depth(),
}
}
pub fn undo(&mut self, options: CalculationOptions) -> Result<MutationResult> {
if self.is_collaborative() {
return self.collaborative_history_step(options, false);
}
let active_name = self.active_sheet_name();
let names_before = self.sheet_names();
let Some(ops) = self.undo.next_undo().map(<[Op]>::to_vec) else {
return Ok(MutationResult::default());
};
let update = self
.authority
.apply_ops(&ops, SyncOrigin::Undo, &self.model.styles)
.map_err(authority_error)?;
let prior_styles = self.pre_edit_cell_styles(&ops);
self.undo.undo(&mut self.model)?;
self.update_sheet_info_cache(&ops, &prior_styles);
if let Some(history) = self.preserved_undo.pop() {
self.preserved = history.before.clone();
self.preserved_redo.push(history);
} else {
self.apply_preserved_state_ops(&names_before, &ops);
}
self.restore_active_sheet(active_name.as_deref());
if ops.iter().any(invalidates_proposals) {
self.proposals.clear();
}
let result = self.rebuild_and_recalculate(options);
self.publish(update);
Ok(MutationResult {
applied: true,
changed: result.changed,
cycle_cells: result.cycle_cells,
limited_cells: result.limited_cells,
})
}
pub fn redo(&mut self, options: CalculationOptions) -> Result<MutationResult> {
if self.is_collaborative() {
return self.collaborative_history_step(options, true);
}
let active_name = self.active_sheet_name();
let names_before = self.sheet_names();
let Some(ops) = self.undo.next_redo().map(<[Op]>::to_vec) else {
return Ok(MutationResult::default());
};
let update = self
.authority
.apply_ops(&ops, SyncOrigin::Redo, &self.model.styles)
.map_err(authority_error)?;
let prior_styles = self.pre_edit_cell_styles(&ops);
self.undo.redo(&mut self.model)?;
self.update_sheet_info_cache(&ops, &prior_styles);
if let Some(history) = self.preserved_redo.pop() {
self.preserved = history.after.clone();
self.preserved_undo.push(history);
} else {
self.apply_preserved_state_ops(&names_before, &ops);
}
self.restore_active_sheet(active_name.as_deref());
if ops.iter().any(invalidates_proposals) {
self.proposals.clear();
}
let result = self.rebuild_and_recalculate(options);
self.publish(update);
Ok(MutationResult {
applied: true,
changed: result.changed,
cycle_cells: result.cycle_cells,
limited_cells: result.limited_cells,
})
}
fn collaborative_history_step(
&mut self,
options: CalculationOptions,
redo: bool,
) -> Result<MutationResult> {
let checkpoint = self.authority.checkpoint();
let stepped = if redo {
self.authority.redo()
} else {
self.authority.undo()
};
let outcome = match stepped {
Ok(history) => self.apply_collaborative_history(history, options),
Err(error) => Err(authority_error(error)),
};
match outcome {
Ok(result) => Ok(result),
Err(error) => {
self.authority
.restore(checkpoint)
.map_err(authority_error)?;
Err(error)
}
}
}
fn apply_collaborative_history(
&mut self,
history: Option<HistoryUpdate>,
options: CalculationOptions,
) -> Result<MutationResult> {
let Some(history) = history else {
return Ok(MutationResult::default());
};
let structure = match &self.mode {
WorkbookMode::Collaborative { structure } => structure,
WorkbookMode::Standalone => return Err(Error::NotCollaborative),
};
if &history.structure != structure {
return Err(Error::CollaborativeStructureChanged);
}
let active_name = self.active_sheet_name();
let before = self.model.clone();
let mut restored = history.model;
retain_array_formulas(&self.model, &mut restored);
self.install_model(restored)?;
self.invalidate_sheet_info();
self.edited_since_open = true;
self.restore_active_sheet(active_name.as_deref());
self.preserved.forget_shared_strings();
self.preserved.forget_axes();
self.proposals.clear();
let result = self.rebuild_and_recalculate(options);
let changed = changed_cells_between(&before, &self.model);
self.publish(Some(history.update));
Ok(MutationResult {
applied: true,
changed,
cycle_cells: result.cycle_cells,
limited_cells: result.limited_cells,
})
}
pub fn propose(
&mut self,
request: ProposalRequest,
options: CalculationOptions,
) -> Result<Proposal> {
let mut preview = self.model.clone();
for edit in &request.edits {
self.validate_target(edit.sheet, edit.cell)?;
let state = edit_cell_state(&preview, edit.sheet, edit.cell, &edit.input);
validate_cell_state(&state)?;
preview
.sheet_mut(edit.sheet)
.ok_or(Error::SheetOutOfRange(edit.sheet))?
.set_cell(edit.cell, state.into());
if let Some(format) = &edit.number_format {
apply_proposed_number_format(&mut preview, edit.sheet, edit.cell, format)?;
}
}
rebuild_and_recalc_all(&mut preview, options.now_serial);
let mut edits = Vec::with_capacity(request.edits.len());
for edit in request.edits {
edits.push(ProposedEdit {
sheet: edit.sheet.0,
row: edit.cell.row,
col: edit.cell.col,
input: edit.input,
old_state: current_cell_state(&self.model, edit.sheet, edit.cell),
number_format: edit.number_format,
a1: edit.cell.to_a1(),
old_text: display_text_at(&self.model, edit.sheet, edit.cell)?,
new_text: display_text_at(&preview, edit.sheet, edit.cell)?,
});
}
let ghosts = proposal_ghosts(&self.model, &preview, &edits)?;
let proposal = Proposal {
id: self.proposals.next_id(),
agent_id: request.agent_id,
note: request.note,
edits,
ghosts,
};
self.proposals.add(proposal.clone());
Ok(proposal)
}
pub fn proposals(&self) -> &[Proposal] {
self.proposals.list()
}
pub fn accept_proposal(
&mut self,
id: &str,
force: bool,
options: CalculationOptions,
) -> Result<ProposalAcceptance> {
let proposal = self
.proposals
.list()
.iter()
.find(|proposal| proposal.id == id)
.cloned()
.ok_or_else(|| Error::ProposalNotFound(id.to_string()))?;
if proposal.edits.is_empty() {
self.proposals.remove(id);
return Ok(ProposalAcceptance {
proposal_id: id.to_string(),
mutation: MutationResult::default(),
});
}
if !force {
let mut stale = Vec::new();
for edit in &proposal.edits {
let address = CellAddress {
sheet: SheetId(edit.sheet),
cell: CellRef::new(edit.row, edit.col),
};
if current_cell_state(&self.model, address.sheet, address.cell) != edit.old_state {
stale.push(address);
}
}
if !stale.is_empty() {
return Err(Error::StaleProposal(stale));
}
}
let mut touched = Vec::with_capacity(proposal.edits.len());
let mut ops = Vec::with_capacity(proposal.edits.len());
let mut per_op = Vec::with_capacity(proposal.edits.len());
let mut preview = self.model.clone();
for edit in &proposal.edits {
let sheet = SheetId(edit.sheet);
let cell = CellRef::new(edit.row, edit.col);
self.validate_target(sheet, cell)?;
let state = edit_cell_state(&preview, sheet, cell, &edit.input);
validate_cell_state(&state)?;
if !cell_states_semantically_equal(¤t_cell_state(&preview, sheet, cell), &state) {
touched.push((sheet, cell, state.formula.clone()));
let op = Op::SetCell {
sheet,
at: cell,
cell: state,
};
per_op.push(xlsx_ops::apply_in_place(&mut preview, &op)?.0);
ops.push(op);
}
if let Some(format) = &edit.number_format {
let op = Op::SetRangeNumberFormat {
sheet,
range: CellRange::new(cell, cell),
format: format.clone(),
};
per_op.push(xlsx_ops::apply_in_place(&mut preview, &op)?.0);
ops.push(op);
}
}
if ops.is_empty() || models_semantically_equal(&preview, &self.model) {
self.proposals.remove(id);
return Ok(ProposalAcceptance {
proposal_id: id.to_string(),
mutation: MutationResult::default(),
});
}
if !force {
let mut review = preview.clone();
rebuild_and_recalc_all(&mut review, options.now_serial);
let mut refreshed = proposal.clone();
for edit in &mut refreshed.edits {
edit.new_text = display_text_at(
&review,
SheetId(edit.sheet),
CellRef::new(edit.row, edit.col),
)?;
}
refreshed.ghosts = proposal_ghosts(&self.model, &review, &refreshed.edits)?;
if refreshed.ghosts != proposal.ghosts {
let targets = refreshed
.edits
.iter()
.map(|edit| CellAddress {
sheet: SheetId(edit.sheet),
cell: CellRef::new(edit.row, edit.col),
})
.collect();
*self.proposals.get_mut(id).expect("proposal exists") = refreshed;
return Err(Error::StaleProposal(targets));
}
}
let mut inverse = Vec::new();
for chunk in per_op.into_iter().rev() {
inverse.extend(chunk);
}
self.ensure_graph();
let prepared = self.prepare_commit(
ops,
StagedApply::new(preview, inverse),
SyncOrigin::Agent,
CommitHistory::Separate,
None,
)?;
let update = self.commit_prepared(prepared)?;
for (sheet, cell, formula) in &touched {
self.graph.as_mut().expect("graph initialized").set_formula(
*sheet,
*cell,
formula.as_deref(),
);
}
let seeds: Vec<_> = touched
.iter()
.map(|(sheet, cell, _)| (*sheet, *cell))
.collect();
let result = recalc_after(
&mut self.model,
self.graph.as_mut().expect("graph initialized"),
&seeds,
options.now_serial,
);
let mutation = self.mutation_result(true, result, &seeds);
self.proposals.remove(id);
self.publish(update);
Ok(ProposalAcceptance {
proposal_id: id.to_string(),
mutation,
})
}
pub fn reject_proposal(&mut self, id: &str) -> bool {
self.proposals.remove(id)
}
pub fn print_display_list(
&self,
sheet: SheetId,
range: CellRange,
metrics: &PrintMetrics,
gridlines: bool,
) -> Result<DisplayList> {
validate_cell_ref(range.start)?;
validate_cell_ref(range.end)?;
if range.start.row > range.end.row || range.start.col > range.end.col || !metrics.is_valid()
{
return Err(Error::InvalidViewport);
}
let cells = (u64::from(range.end.row - range.start.row) + 2)
* (u64::from(range.end.col - range.start.col) + 2);
if cells > MAX_DISPLAY_CELLS {
return Err(Error::DisplayTooLarge {
cells,
max: MAX_DISPLAY_CELLS,
});
}
let sheet_ref = self.sheet(sheet)?;
let geometry = GridGeometry::for_print(sheet_ref, &self.model.styles, metrics);
let viewport = Viewport {
x: geometry.col_x(range.start.col),
y: geometry.row_y(range.start.row),
width: geometry.col_x(range.end.col + 1) - geometry.col_x(range.start.col),
height: geometry.row_y(range.end.row + 1) - geometry.row_y(range.start.row),
};
validate_viewport(&viewport)?;
let mut frame = build_print_display_list_with_charts(
&self.model,
sheet,
&viewport,
metrics,
gridlines,
|chart| self.resolve_chart_space(&sheet_ref.name, chart),
)
.map_err(Error::from)?;
if gridlines {
frame.width += 96.0 / metrics.dpi;
frame.height += 96.0 / metrics.dpi;
}
Ok(frame)
}
pub fn display_list(&self, viewport: &Viewport) -> Result<DisplayList> {
self.display_list_for(self.active_sheet, viewport)
}
pub fn display_list_for(&self, sheet: SheetId, viewport: &Viewport) -> Result<DisplayList> {
let sheet_ref = self.sheet(sheet)?;
validate_display_region(sheet_ref, &self.model.styles, viewport)?;
let mut ghosts: BTreeMap<(u32, u32), GhostEdit> = BTreeMap::new();
for proposal in self.proposals.list() {
let drifted: BTreeSet<_> = proposal
.edits
.iter()
.filter_map(|edit| {
let at = CellRef::new(edit.row, edit.col);
(current_cell_state(&self.model, SheetId(edit.sheet), at) != edit.old_state)
.then_some((edit.sheet, edit.row, edit.col))
})
.collect();
let direct: BTreeSet<_> = proposal
.edits
.iter()
.map(|edit| (edit.sheet, edit.row, edit.col))
.collect();
for ghost in &proposal.ghosts {
let address = (ghost.sheet, ghost.row, ghost.col);
let is_direct = direct.contains(&address);
if ghost.sheet != sheet.0
|| drifted.contains(&address)
|| (!is_direct && !drifted.is_empty())
{
continue;
}
ghosts.insert(
(ghost.row, ghost.col),
GhostEdit {
row: ghost.row,
col: ghost.col,
old_text: ghost.old_text.clone(),
new_text: ghost.new_text.clone(),
alignment_value: ghost.alignment_value.clone(),
},
);
}
}
let ghosts: Vec<GhostEdit> = ghosts.into_values().collect();
let owner = sheet_ref.name.clone();
build_display_list_with_charts_and_ghosts(&self.model, sheet, viewport, &ghosts, |chart| {
self.resolve_chart_space(&owner, chart)
})
.map_err(Error::from)
}
pub fn chart_at_point(
&self,
viewport: &Viewport,
x: f32,
y: f32,
) -> Result<Option<ChartRegion>> {
self.chart_at_point_on(self.active_sheet, viewport, x, y)
}
pub fn chart_at_point_on(
&self,
sheet: SheetId,
viewport: &Viewport,
x: f32,
y: f32,
) -> Result<Option<ChartRegion>> {
let regions = chart_regions(self.sheet(sheet)?, &self.model.styles, viewport)?;
Ok(chart_at_point(®ions, x, y).cloned())
}
pub fn move_chart(
&mut self,
sheet: SheetId,
frame: &str,
dx: f32,
dy: f32,
options: CalculationOptions,
) -> Result<MutationResult> {
let sheet_ref = self.sheet(sheet)?;
let chart = sheet_ref
.charts
.iter()
.find(|chart| chart.frame_id() == frame)
.ok_or_else(|| chart_frame_not_found(frame))?;
let to = moved_chart_anchor(
chart.anchor,
&GridGeometry::new(sheet_ref, &self.model.styles),
f64::from(dx),
f64::from(dy),
)
.ok_or_else(|| {
Error::InvalidOperation(format!(
"chart {frame} is pinned to the sheet and cannot be moved"
))
})?;
let ops = self
.model
.sheets
.iter()
.enumerate()
.flat_map(|(index, sheet)| {
sheet
.charts
.iter()
.filter(|held| held.frame_id() == frame)
.map(move |held| Op::SetChartAnchor {
sheet: SheetId(index as u32),
frame: frame.to_owned(),
part: held.part.clone(),
from: held.anchor,
to,
})
})
.collect();
self.apply_ops(ops, options)
}
#[cfg(feature = "raster")]
pub fn render_png(&self, viewport: &Viewport) -> Result<RenderedPng> {
self.render_png_for(self.active_sheet, viewport)
}
#[cfg(feature = "raster")]
pub fn render_png_for(&self, sheet: SheetId, viewport: &Viewport) -> Result<RenderedPng> {
validate_viewport(viewport)?;
let width = viewport.width.ceil().max(1.0) as u32;
let height = viewport.height.ceil().max(1.0) as u32;
validate_render_size(width, height)?;
let display_list = self.display_list_for(sheet, viewport)?;
let bytes = xlsx_raster::render_png(&display_list).map_err(Error::Raster)?;
Ok(RenderedPng {
bytes,
width,
height,
})
}
#[cfg(feature = "raster")]
pub fn render_sheet(&self, sheet: SheetId, options: &RenderOptions) -> Result<RenderedPng> {
if !(options.scale.is_finite() && options.scale > 0.0) {
return Err(Error::InvalidScale(options.scale));
}
let sheet_ref = self.sheet(sheet)?;
if let Some(range) = options.range {
validate_range(range)?;
}
let mut viewport = match options.range {
Some(range) => viewport_for_range(sheet_ref, &self.model.styles, range),
None => viewport_for_used_range_within(sheet_ref, &self.model.styles, |grown| {
renderable(sheet_ref, &self.model.styles, grown, options.scale)
}),
};
if let Some(width) = options.max_width {
viewport.width = viewport.width.min(width as f32 / options.scale);
}
if let Some(height) = options.max_height {
viewport.height = viewport.height.min(height as f32 / options.scale);
}
validate_viewport(&viewport)?;
let width = ((viewport.width * options.scale).ceil() as u32).max(1);
let height = ((viewport.height * options.scale).ceil() as u32).max(1);
validate_render_size(width, height)?;
validate_display_region(sheet_ref, &self.model.styles, &viewport)?;
let owner = sheet_ref.name.clone();
let display_list =
build_display_list_with_charts(&self.model, sheet, &viewport, |chart| {
self.resolve_chart_space(&owner, chart)
})?;
let display_list = if options.scale == 1.0 {
display_list
} else {
scaled(display_list, options.scale)
};
let bytes = xlsx_raster::render_png(&display_list).map_err(Error::Raster)?;
Ok(RenderedPng {
bytes,
width,
height,
})
}
pub fn format_address(&self, address: CellAddress) -> String {
if address.sheet == self.active_sheet {
address.cell.to_a1()
} else {
let name = self
.model
.sheet(address.sheet)
.map(|sheet| sheet.name.as_str())
.unwrap_or_default();
format!("{name}!{}", address.cell.to_a1())
}
}
fn validate_target(&self, sheet: SheetId, cell: CellRef) -> Result<()> {
self.ensure_worksheet_sheet(sheet)?;
self.validate_cell(cell)
}
fn validate_bounded_range(&self, sheet: SheetId, range: CellRange) -> Result<(u64, u64)> {
validate_range(range)?;
self.ensure_worksheet_sheet(sheet)?;
let rows = u64::from(range.end.row - range.start.row + 1);
let columns = u64::from(range.end.col - range.start.col + 1);
if rows * columns > MAX_RANGE_CELLS {
return Err(Error::RangeTooLarge {
rows,
cols: columns,
max: MAX_RANGE_CELLS,
});
}
Ok((rows, columns))
}
fn range_formats(
&self,
sheet: SheetId,
range: CellRange,
) -> Result<Vec<(CellRef, CellFormat)>> {
let sheet_ref = self.sheet(sheet)?;
let mut formats = Vec::new();
for row in range.start.row..=range.end.row {
for col in range.start.col..=range.end.col {
let at = CellRef::new(row, col);
let style = sheet_ref.cell(at).and_then(|cell| cell.style);
formats.push((at, self.model.styles.cell_format(style)));
}
}
Ok(formats)
}
fn validate_cell(&self, cell: CellRef) -> Result<()> {
validate_cell_ref(cell)
}
fn ensure_references_stay_valid(&self, names: &[String], op: &Op) -> Result<()> {
let Some(package) = self.source_package.as_ref() else {
return Ok(());
};
let at = |sheet: SheetId| {
names
.get(sheet.0 as usize)
.ok_or(Error::SheetOutOfRange(sheet))
};
let stranded = match op {
Op::RenameSheet { sheet, name } => {
let current = at(*sheet)?;
(current != name)
.then(|| package.reference_naming_sheet(current))
.flatten()
}
Op::RemoveSheet { index } => {
package.reference_naming_sheet(at(SheetId(*index as u32))?)
}
Op::InsertRows { sheet, at: row, .. } | Op::DeleteRows { sheet, at: row, .. } => {
package.reference_moved_by_rows(at(*sheet)?, *row)
}
Op::InsertCols { sheet, at: col, .. } | Op::DeleteCols { sheet, at: col, .. } => {
package.reference_moved_by_cols(at(*sheet)?, *col)
}
_ => return Ok(()),
};
match stranded {
Some(part) => Err(Error::InvalidOperation(format!(
"{part} references cells this edit would move, and it cannot be rewritten"
))),
None => Ok(()),
}
}
fn sheet_names(&self) -> Vec<String> {
self.model
.sheets
.iter()
.map(|sheet| sheet.name.clone())
.collect()
}
fn moves_referenced_cells(&self, names: &[String], op: &Op) -> bool {
let Some(package) = self.source_package.as_ref() else {
return false;
};
let (sheet, at, by_rows) = match *op {
Op::InsertRows { sheet, at, .. } | Op::DeleteRows { sheet, at, .. } => {
(sheet, at, true)
}
Op::InsertCols { sheet, at, .. } | Op::DeleteCols { sheet, at, .. } => {
(sheet, at, false)
}
_ => return false,
};
let Some(name) = names.get(sheet.0 as usize) else {
return true;
};
if by_rows {
package.reference_moved_by_rows(name, at).is_some()
} else {
package.reference_moved_by_cols(name, at).is_some()
}
}
fn ensure_worksheet_sheet(&self, sheet: SheetId) -> Result<()> {
self.sheet(sheet)?;
let origin = self
.preserved
.origins
.get(sheet.0 as usize)
.copied()
.flatten();
if origin.is_some_and(|origin| {
self.source_package
.as_ref()
.is_some_and(|package| !package.source_sheet_is_worksheet(origin))
}) {
return Err(Error::InvalidOperation(format!(
"sheet {} is not an editable worksheet",
sheet.0
)));
}
Ok(())
}
fn commit_user(&mut self, ops: &[Op]) -> Result<Option<Vec<u8>>> {
self.bump_model_epoch();
let preserved_before = (!self.is_collaborative()).then(|| self.preserved.clone());
let names_before = self.sheet_names();
let prior_styles = self.pre_edit_cell_styles(ops);
let update = if self.is_collaborative() {
let staged = self.stage_local_update(ops, SyncOrigin::User)?;
self.authority
.apply_local_update_v1(&staged.update, SyncOrigin::User)
.map_err(authority_error)?;
let mut model = staged.model;
retain_formula_caches(&self.model, &mut model);
retain_array_formulas(&self.model, &mut model);
self.install_model(model)?;
self.update_sheet_info_cache(ops, &prior_styles);
Some(staged.update)
} else {
let update = self
.authority
.apply_ops(ops, SyncOrigin::User, &self.model.styles)
.map_err(authority_error)?;
let transaction = Transaction::new(ops.to_vec(), Provenance::User);
self.undo.commit(&mut self.model, &transaction)?;
self.update_sheet_info_cache(ops, &prior_styles);
update
};
self.apply_preserved_state_ops(&names_before, ops);
if let Some(before) = preserved_before {
self.preserved_undo.push(PreservedStateHistory {
before,
after: self.preserved.clone(),
});
self.preserved_redo.clear();
}
self.edited_since_open = true;
Ok(update)
}
fn publish(&mut self, update: Option<Vec<u8>>) {
self.committed_changes += 1;
if let Some(update) = update {
self.emit_update(UpdateEvent {
update,
origin: UpdateOrigin::Local,
});
}
}
fn stage_local_update(&self, ops: &[Op], origin: SyncOrigin) -> Result<StagedLocalUpdate> {
let structure = match &self.mode {
WorkbookMode::Collaborative { structure } => structure,
WorkbookMode::Standalone => return Err(Error::NotCollaborative),
};
let staged = self
.authority
.stage_local_ops_v1(ops, origin, &self.model.styles)
.map_err(authority_error)?;
if &staged.structure != structure {
return Err(Error::CollaborativeStructureChanged);
}
validate_collaboration_size(&staged.update)?;
validate_collaboration_state(staged.state_bytes, staged.state_vector_entries)?;
Ok(staged)
}
fn emit_update(&self, event: UpdateEvent) {
let listeners = self
.update_observers
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.listeners
.values()
.cloned()
.collect::<Vec<_>>();
for listener in listeners {
let event = event.clone();
let _ = catch_unwind(AssertUnwindSafe(|| listener(event)));
}
}
fn invalidate_sheet_info(&self) {
*self
.sheet_info_cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
fn pre_edit_cell_styles(&self, ops: &[Op]) -> HashMap<CellRef, Option<u32>> {
if self
.sheet_info_cache
.lock()
.unwrap_or_else(|p| p.into_inner())
.is_none()
{
return HashMap::new();
}
ops.iter()
.filter_map(|op| match op {
Op::SetCell { sheet, at, .. } if *sheet == self.active_sheet => Some(*at),
_ => None,
})
.filter_map(|at| {
self.model
.sheet(self.active_sheet)
.and_then(|sheet| sheet.cell(at))
.map(|cell| (at, cell.style))
})
.collect()
}
fn update_sheet_info_cache(&self, ops: &[Op], prior_styles: &HashMap<CellRef, Option<u32>>) {
let mut slot = self
.sheet_info_cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let Some(cache) = slot.as_mut() else {
return;
};
for op in ops {
match op {
Op::AddSheet { .. }
| Op::RemoveSheet { .. }
| Op::RenameSheet { .. }
| Op::RestoreSheet { .. } => {
*slot = None;
return;
}
Op::InsertRows { sheet, .. }
| Op::DeleteRows { sheet, .. }
| Op::InsertCols { sheet, .. }
| Op::DeleteCols { sheet, .. }
| Op::SetColWidth { sheet, .. }
| Op::SetRowHeight { sheet, .. }
| Op::SetFreezePane { sheet, .. }
| Op::SetHyperlinks { sheet, .. }
| Op::RestoreColStyles { sheet, .. }
| Op::MergeCells { sheet, .. }
| Op::UnmergeCells { sheet, .. }
| Op::PatchRangeStyle { sheet, .. }
| Op::SetRangeNumberFormat { sheet, .. }
| Op::ApplyRangeFormat { sheet, .. } => {
if *sheet == self.active_sheet {
*slot = None;
return;
}
}
Op::SetCell { sheet, at, cell } => {
if *sheet != self.active_sheet {
continue;
}
let prior = prior_styles.get(at);
let invalid = if *cell == CellState::default() {
prior.is_some()
&& (on_used_edge(cache.bounds, *at)
|| self.cell_moves_row_fit(*at, prior.copied().flatten()))
} else {
match prior {
Some(&prior) if prior != cell.style => true,
Some(_) => false,
None => self.cell_moves_row_fit(*at, cell.style),
}
};
if invalid {
*slot = None;
return;
}
cache.extend_bounds(*at, self.active_sheet_freeze_pane());
}
Op::SetCharts { .. } | Op::SetChartAnchor { .. } | Op::SetDefinedNames { .. } => {}
}
}
}
fn cell_moves_row_fit(&self, at: CellRef, style: Option<u32>) -> bool {
self.model
.sheet(self.active_sheet)
.is_some_and(|sheet| autofit_relevant(sheet, &self.model.styles, at, style))
}
fn active_sheet_freeze_pane(&self) -> Option<FreezePane> {
self.model
.sheet(self.active_sheet)
.and_then(|sheet| sheet.freeze_pane)
}
fn rebuild_and_recalculate(&mut self, options: CalculationOptions) -> CalculationResult {
self.bump_model_epoch();
self.edited_since_open = true;
let (graph, result) = rebuild_and_recalc_all(&mut self.model, options.now_serial);
self.graph = Some(graph);
let result = calculation_result(&result);
self.last_calculation = result.clone();
result
}
fn ensure_graph(&mut self) {
if self.graph.is_none() {
self.graph = Some(DepGraph::build(&self.model));
}
}
fn mutation_result(
&mut self,
applied: bool,
result: RecalcResult,
seeds: &[(SheetId, CellRef)],
) -> MutationResult {
let seeds: HashSet<_> = seeds
.iter()
.map(|(sheet, cell)| (sheet.0, cell.row, cell.col))
.collect();
self.last_calculation = calculation_result(&result);
MutationResult {
applied,
changed: result
.changed
.into_iter()
.filter(|(sheet, cell)| !seeds.contains(&(sheet.0, cell.row, cell.col)))
.map(|(sheet, cell)| CellAddress { sheet, cell })
.collect(),
cycle_cells: result
.cycle_cells
.into_iter()
.map(|(sheet, cell)| CellAddress { sheet, cell })
.collect(),
limited_cells: result
.limited_cells
.into_iter()
.map(|(sheet, cell)| CellAddress { sheet, cell })
.collect(),
}
}
fn active_sheet_name(&self) -> Option<String> {
self.model
.sheet(self.active_sheet)
.map(|sheet| sheet.name.clone())
}
fn apply_preserved_state_ops(&mut self, before: &[String], ops: &[Op]) {
let mut names = before.to_vec();
for op in ops {
self.moved_references_since_open |= self.moves_referenced_cells(&names, op);
rename_sheet_view(&mut names, op);
}
for op in ops {
match *op {
Op::AddSheet { index, .. } => self.preserved.insert(index),
Op::RemoveSheet { index } => self.preserved.remove(index),
Op::InsertRows { sheet, .. }
| Op::DeleteRows { sheet, .. }
| Op::InsertCols { sheet, .. }
| Op::DeleteCols { sheet, .. } => self.preserved.shift(sheet, op),
_ => {}
}
}
}
fn restore_active_sheet(&mut self, previous_name: Option<&str>) {
if let Some(name) = previous_name
&& let Some((sheet, _)) = self.model.sheet_by_name(name)
{
self.active_sheet = sheet;
return;
}
let last = self.model.sheets.len().saturating_sub(1) as u32;
self.active_sheet = SheetId(self.active_sheet.0.min(last));
}
pub fn last_calculation(&self) -> &CalculationResult {
&self.last_calculation
}
}
fn uniform<T: PartialEq + Clone>(mut values: impl Iterator<Item = T>) -> Option<T> {
let first = values.next()?;
values.all(|value| value == first).then_some(first)
}
fn number_format_kind(format: &NumberFormat) -> (NumberFormatKind, Option<String>) {
match format {
NumberFormat::Builtin { id: 0 } => (NumberFormatKind::Automatic, None),
NumberFormat::Builtin { id: 49 } => (NumberFormatKind::PlainText, None),
NumberFormat::Builtin { id: 9 | 10 } => (NumberFormatKind::Percent, None),
NumberFormat::Builtin { id: 11 | 48 } => (NumberFormatKind::Scientific, None),
NumberFormat::Builtin {
id: 5..=8 | 41..=44,
} => (NumberFormatKind::Currency, None),
NumberFormat::Builtin { id: 14..=17 | 22 } => (NumberFormatKind::Date, None),
NumberFormat::Builtin {
id: 18..=21 | 45..=47,
} => (NumberFormatKind::Time, None),
NumberFormat::Builtin {
id: 1..=4 | 37..=40,
} => (NumberFormatKind::Number, None),
NumberFormat::Builtin { .. } => (NumberFormatKind::Custom, None),
NumberFormat::Custom { pattern }
if pattern.contains('$')
|| pattern.contains('€')
|| pattern.contains('£')
|| pattern.contains('¥') =>
{
(NumberFormatKind::Currency, Some(pattern.clone()))
}
NumberFormat::Custom { pattern } if pattern.contains('%') => {
(NumberFormatKind::Percent, Some(pattern.clone()))
}
NumberFormat::Custom { pattern } if pattern.contains("E+") || pattern.contains("E-") => {
(NumberFormatKind::Scientific, Some(pattern.clone()))
}
NumberFormat::Custom { pattern } => (NumberFormatKind::Custom, Some(pattern.clone())),
}
}
fn border_edges(border: &Border) -> [Option<&BorderEdge>; 4] {
[
border.left.as_ref(),
border.top.as_ref(),
border.right.as_ref(),
border.bottom.as_ref(),
]
}
fn uniform_border_value<T: PartialEq + Clone>(
formats: &[(CellRef, CellFormat)],
value: impl Fn(&BorderEdge) -> T,
) -> Option<T> {
uniform(
formats
.iter()
.flat_map(|(_, format)| border_edges(&format.border))
.flatten()
.map(value),
)
}
fn border_line_style(style: BorderStyle) -> BorderLineStyle {
match style {
BorderStyle::Dashed => BorderLineStyle::Dashed,
BorderStyle::Dotted | BorderStyle::Hair => BorderLineStyle::Dotted,
BorderStyle::Double => BorderLineStyle::Double,
BorderStyle::Thin | BorderStyle::Medium | BorderStyle::Thick => BorderLineStyle::Solid,
}
}
fn detect_border_preset(
formats: &[(CellRef, CellFormat)],
range: CellRange,
) -> Option<BorderPreset> {
if formats
.iter()
.all(|(_, format)| border_edges(&format.border).iter().all(Option::is_none))
{
return Some(BorderPreset::None);
}
[
BorderPreset::All,
BorderPreset::Outer,
BorderPreset::Inner,
BorderPreset::Horizontal,
BorderPreset::Vertical,
BorderPreset::Left,
BorderPreset::Top,
BorderPreset::Right,
BorderPreset::Bottom,
]
.into_iter()
.find(|preset| {
formats.iter().all(|(at, format)| {
let border = &format.border;
border.left.is_some() == border_expected(*preset, range, *at, 0)
&& border.top.is_some() == border_expected(*preset, range, *at, 1)
&& border.right.is_some() == border_expected(*preset, range, *at, 2)
&& border.bottom.is_some() == border_expected(*preset, range, *at, 3)
})
})
}
fn border_expected(preset: BorderPreset, range: CellRange, at: CellRef, side: u8) -> bool {
let boundary = match side {
0 => at.col == range.start.col,
1 => at.row == range.start.row,
2 => at.col == range.end.col,
_ => at.row == range.end.row,
};
match preset {
BorderPreset::All => true,
BorderPreset::Inner => !boundary,
BorderPreset::Horizontal => matches!(side, 1 | 3) && !boundary,
BorderPreset::Vertical => matches!(side, 0 | 2) && !boundary,
BorderPreset::Outer => boundary,
BorderPreset::Left => side == 0 && boundary,
BorderPreset::Top => side == 1 && boundary,
BorderPreset::Right => side == 2 && boundary,
BorderPreset::Bottom => side == 3 && boundary,
BorderPreset::None => false,
}
}
fn ranges_intersect(left: CellRange, right: CellRange) -> bool {
left.start.row <= right.end.row
&& left.end.row >= right.start.row
&& left.start.col <= right.end.col
&& left.end.col >= right.start.col
}
fn authority_error(error: AuthorityError) -> Error {
match error {
AuthorityError::ClientIdConflict(client_id) => Error::ClientIdConflict(client_id),
AuthorityError::InvalidStateVector(error) => Error::InvalidStateVector(error),
AuthorityError::InvalidUpdate(error) => Error::InvalidUpdate(error),
AuthorityError::InvalidState(error) => Error::CollaborativeState(error),
}
}
fn validate_collaboration_client_id(client_id: u64) -> Result<()> {
if client_id > MAX_COLLABORATION_CLIENT_ID {
Err(Error::InvalidClientId {
client_id,
max: MAX_COLLABORATION_CLIENT_ID,
})
} else {
Ok(())
}
}
fn validate_collaboration_size(bytes: &[u8]) -> Result<()> {
if bytes.len() > MAX_COLLABORATION_BYTES {
Err(Error::CollaborationDataTooLarge {
bytes: bytes.len(),
max: MAX_COLLABORATION_BYTES,
})
} else {
Ok(())
}
}
fn validate_collaboration_state(bytes: usize, state_vector_entries: usize) -> Result<()> {
if bytes > MAX_COLLABORATION_BYTES {
return Err(Error::CollaborationDataTooLarge {
bytes,
max: MAX_COLLABORATION_BYTES,
});
}
validate_collaboration_state_entries(state_vector_entries)
}
fn validate_collaboration_state_entries(entries: usize) -> Result<()> {
if entries > MAX_COLLABORATION_STATE_VECTOR_ENTRIES as usize {
Err(Error::CollaborativeState(format!(
"state vector contains {entries} entries, exceeds the {MAX_COLLABORATION_STATE_VECTOR_ENTRIES}-entry limit"
)))
} else {
Ok(())
}
}
fn profiled<T>(
now: &mut impl FnMut() -> f64,
run: impl FnOnce(&mut dyn FnMut(EditStage)) -> Result<T>,
) -> Result<(T, EditProfile)> {
let started = now();
let mut stamps: Vec<(EditStage, f64)> = Vec::with_capacity(3);
let value = run(&mut |stage| stamps.push((stage, now())))?;
let finished = now();
let at = |stage: EditStage| stamps.iter().find(|(s, _)| *s == stage).map(|(_, t)| *t);
let validated = at(EditStage::Validated).unwrap_or(finished);
let applied = at(EditStage::Applied).unwrap_or(validated);
let recalculated = at(EditStage::Recalculated).unwrap_or(applied);
Ok((
value,
EditProfile {
validate_ms: validated - started,
apply_ms: applied - validated,
recalc_ms: recalculated - applied,
result_ms: finished - recalculated,
},
))
}
fn calculation_result(result: &RecalcResult) -> CalculationResult {
CalculationResult {
changed: result
.changed
.iter()
.map(|&(sheet, cell)| CellAddress { sheet, cell })
.collect(),
cycle_cells: result
.cycle_cells
.iter()
.map(|&(sheet, cell)| CellAddress { sheet, cell })
.collect(),
limited_cells: result
.limited_cells
.iter()
.map(|&(sheet, cell)| CellAddress { sheet, cell })
.collect(),
}
}
fn retain_array_formulas(current: &WorkbookModel, projected: &mut WorkbookModel) {
for (index, sheet) in projected.sheets.iter_mut().enumerate() {
let Some(source) = current.sheets.get(index) else {
continue;
};
let anchors: Vec<_> = source
.array_formulas()
.filter(|(at, _)| {
let formula = sheet.cell(*at).and_then(|cell| cell.formula.as_deref());
formula.is_some()
&& formula == source.cell(*at).and_then(|cell| cell.formula.as_deref())
})
.collect();
for (at, range) in anchors {
sheet.set_array_formula(at, range);
}
}
}
fn retain_formula_caches(current: &WorkbookModel, projected: &mut WorkbookModel) {
for (sheet_index, sheet) in projected.sheets.iter_mut().enumerate() {
let Some(current_sheet) = current.sheets.get(sheet_index) else {
continue;
};
let caches = sheet
.iter_cells()
.filter_map(|(at, cell)| {
let formula = cell.formula.as_deref()?;
let current_cell = current_sheet.cell(at)?;
(current_cell.formula.as_deref() == Some(formula))
.then(|| (at, current_cell.value.clone()))
})
.collect::<Vec<_>>();
for (at, value) in caches {
let mut cell = sheet.cell(at).cloned().expect("cache target exists");
cell.value = value;
sheet.set_cell(at, cell);
}
}
}
fn changed_cells_between(before: &WorkbookModel, after: &WorkbookModel) -> Vec<CellAddress> {
let mut changed = Vec::new();
for sheet_index in 0..before.sheets.len().max(after.sheets.len()) {
let sheet = SheetId(sheet_index as u32);
let mut cells = BTreeSet::new();
if let Some(before_sheet) = before.sheets.get(sheet_index) {
cells.extend(
before_sheet
.iter_cells()
.map(|(cell, _)| (cell.row, cell.col)),
);
}
if let Some(after_sheet) = after.sheets.get(sheet_index) {
cells.extend(
after_sheet
.iter_cells()
.map(|(cell, _)| (cell.row, cell.col)),
);
}
for (row, col) in cells {
let cell = CellRef::new(row, col);
let before_cell = before
.sheets
.get(sheet_index)
.and_then(|sheet| sheet.cell(cell));
let after_cell = after
.sheets
.get(sheet_index)
.and_then(|sheet| sheet.cell(cell));
if before_cell != after_cell {
changed.push(CellAddress { sheet, cell });
}
}
}
changed
}
fn validate_model(model: &WorkbookModel) -> Result<()> {
validate_model_sheets(model)
}
struct StagedApply {
model: WorkbookModel,
inverse: Vec<Op>,
}
impl StagedApply {
fn new(model: WorkbookModel, inverse: Vec<Op>) -> Self {
Self { model, inverse }
}
}
impl Workbook {
fn bump_model_epoch(&mut self) {
self.model_epoch = self.model_epoch.wrapping_add(1);
self.chart_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
fn install_model(&mut self, model: WorkbookModel) -> Result<()> {
self.model = model;
self.model_epoch = self.model_epoch.wrapping_add(1);
self.chart_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
Ok(())
}
}
fn validate_model_sheets(model: &WorkbookModel) -> Result<()> {
if model.sheets.is_empty() {
return Err(Error::NoSheets);
}
for defined in &model.defined_names {
if defined
.local_sheet
.is_some_and(|sheet| sheet.0 as usize >= model.sheets.len())
{
return Err(Error::InvalidOperation(format!(
"defined name {} has an invalid sheet scope",
defined.name
)));
}
if defined.formula.len() > xlsx_calc::lexer::MAX_FORMULA_BYTES {
return Err(Error::InvalidOperation(format!(
"defined name {} has a formula above the length limit",
defined.name
)));
}
}
let mut names = HashSet::with_capacity(model.sheets.len());
for sheet in &model.sheets {
if let Some(pane) = sheet.freeze_pane
&& (pane.rows > MAX_ROWS
|| pane.cols > MAX_COLS
|| pane.top_left.row >= MAX_ROWS
|| pane.top_left.col >= MAX_COLS)
{
return Err(Error::InvalidOperation(format!(
"sheet {} has an invalid freeze pane",
sheet.name
)));
}
validate_hyperlinks(&sheet.hyperlinks)?;
validate_charts(&sheet.charts)?;
validate_sheet_name(&sheet.name)?;
if !names.insert(sheet.name.to_lowercase()) {
return Err(Error::InvalidOperation(format!(
"duplicate sheet name: {}",
sheet.name
)));
}
for (cell, stored) in sheet.iter_cells() {
validate_cell_ref(cell)?;
if matches!(stored.value, CellValue::Number { value } if !value.is_finite()) {
return Err(Error::InvalidOperation(
"workbook contains a non-finite cell number".to_string(),
));
}
if matches!(&stored.value, CellValue::Text { value } if value.chars().count() > xlsx_calc::eval::MAX_CELL_TEXT_CHARS)
{
return Err(Error::InvalidOperation(
"workbook contains cell text above Excel's length limit".to_string(),
));
}
if stored
.formula
.as_ref()
.is_some_and(|formula| formula.len() > xlsx_calc::lexer::MAX_FORMULA_BYTES)
{
return Err(Error::InvalidOperation(
"workbook contains a formula above the length limit".to_string(),
));
}
if stored
.style
.is_some_and(|style| style as usize >= model.styles.cell_xfs.len().max(1))
{
return Err(Error::InvalidOperation(
"workbook contains an invalid cell style index".to_string(),
));
}
}
for (&column, &width) in &sheet.col_widths {
if column >= MAX_COLS || !width.is_finite() || !(0.0..=MAX_COL_WIDTH).contains(&width) {
return Err(Error::InvalidOperation(
"workbook contains an invalid column width".to_string(),
));
}
}
for (&row, &height) in &sheet.row_heights {
if row >= MAX_ROWS || !height.is_finite() || !(0.0..=MAX_ROW_HEIGHT).contains(&height) {
return Err(Error::InvalidOperation(
"workbook contains an invalid row height".to_string(),
));
}
}
for (index, range) in sheet.merges.iter().enumerate() {
validate_range(*range)?;
if sheet.merges[index + 1..]
.iter()
.any(|other| ranges_intersect(*range, *other))
{
return Err(Error::InvalidOperation(
"workbook contains overlapping merged ranges".to_string(),
));
}
}
}
Ok(())
}
fn validate_shared_drawings(model: &WorkbookModel) -> Result<()> {
let mut claims: HashMap<(&str, usize), ChartAnchor> = HashMap::new();
for sheet in &model.sheets {
for chart in &sheet.charts {
match claims.entry((chart.drawing.as_str(), chart.anchor_index)) {
Entry::Vacant(slot) => {
slot.insert(chart.anchor);
}
Entry::Occupied(slot) => {
if *slot.get() != chart.anchor {
return Err(Error::InvalidOperation(format!(
"anchor {} of {} is held by two sheets that disagree on where it sits",
chart.anchor_index, chart.drawing
)));
}
}
}
}
}
Ok(())
}
fn worksheet_edit_target(op: &Op) -> Option<SheetId> {
match op {
Op::SetCell { sheet, .. }
| Op::InsertRows { sheet, .. }
| Op::DeleteRows { sheet, .. }
| Op::InsertCols { sheet, .. }
| Op::DeleteCols { sheet, .. }
| Op::SetColWidth { sheet, .. }
| Op::SetRowHeight { sheet, .. }
| Op::SetFreezePane { sheet, .. }
| Op::SetHyperlinks { sheet, .. }
| Op::RestoreColStyles { sheet, .. }
| Op::SetCharts { sheet, .. }
| Op::SetChartAnchor { sheet, .. }
| Op::MergeCells { sheet, .. }
| Op::UnmergeCells { sheet, .. }
| Op::PatchRangeStyle { sheet, .. }
| Op::SetRangeNumberFormat { sheet, .. }
| Op::ApplyRangeFormat { sheet, .. } => Some(*sheet),
Op::AddSheet { .. }
| Op::RemoveSheet { .. }
| Op::RenameSheet { .. }
| Op::RestoreSheet { .. }
| Op::SetDefinedNames { .. } => None,
}
}
fn validate_op(model: &WorkbookModel, op: &Op) -> Result<()> {
match op {
Op::SetCell { sheet, at, .. } => {
require_sheet(model, *sheet)?;
validate_cell_ref(*at)?;
}
Op::InsertRows {
sheet, at, count, ..
}
| Op::DeleteRows {
sheet, at, count, ..
} => {
require_sheet(model, *sheet)?;
validate_axis("row", *at, *count, MAX_ROWS)?;
}
Op::InsertCols {
sheet, at, count, ..
}
| Op::DeleteCols {
sheet, at, count, ..
} => {
require_sheet(model, *sheet)?;
validate_axis("column", *at, *count, MAX_COLS)?;
}
Op::SetColWidth { sheet, col, width } => {
require_sheet(model, *sheet)?;
if *col >= MAX_COLS {
return Err(Error::InvalidOperation(format!(
"column {} is out of range",
u64::from(*col) + 1
)));
}
if width
.is_some_and(|width| !width.is_finite() || !(0.0..=MAX_COL_WIDTH).contains(&width))
{
return Err(Error::InvalidOperation(format!(
"column width must be between 0 and {MAX_COL_WIDTH}"
)));
}
}
Op::SetRowHeight { sheet, row, height } => {
require_sheet(model, *sheet)?;
if *row >= MAX_ROWS {
return Err(Error::InvalidOperation(format!(
"row {} is out of range",
u64::from(*row) + 1
)));
}
if height.is_some_and(|height| {
!height.is_finite() || !(0.0..=MAX_ROW_HEIGHT).contains(&height)
}) {
return Err(Error::InvalidOperation(format!(
"row height must be between 0 and {MAX_ROW_HEIGHT}"
)));
}
}
Op::SetFreezePane { sheet, pane } => {
require_sheet(model, *sheet)?;
if pane.is_some_and(|pane| {
pane.rows > MAX_ROWS
|| pane.cols > MAX_COLS
|| pane.top_left.row >= MAX_ROWS
|| pane.top_left.col >= MAX_COLS
}) {
return Err(Error::InvalidOperation(
"freeze pane is out of range".to_string(),
));
}
}
Op::SetHyperlinks { sheet, hyperlinks } => {
require_sheet(model, *sheet)?;
validate_hyperlinks(hyperlinks)?;
}
Op::SetChartAnchor {
sheet,
frame,
part,
from,
to,
} => {
let sheet_ref = require_sheet(model, *sheet)?;
let chart = sheet_ref
.charts
.iter()
.find(|chart| chart.frame_id() == *frame)
.ok_or_else(|| chart_frame_not_found(frame))?;
if !chart.is_recorded_frame(part, *from) {
return Err(Error::ChartFrameShifted {
frame: frame.clone(),
});
}
validate_anchor_change(*from, *to, frame)?;
validate_intrinsic_anchor(*to)?;
resolve_chart_anchor(*to, &GridGeometry::new(sheet_ref, &model.styles), 0, 0)
.map_err(|error| Error::InvalidOperation(error.to_string()))?;
}
Op::MergeCells { sheet, range } | Op::UnmergeCells { sheet, range } => {
require_sheet(model, *sheet)?;
validate_range(*range)?;
}
Op::PatchRangeStyle { sheet, range, .. }
| Op::SetRangeNumberFormat { sheet, range, .. }
| Op::ApplyRangeFormat { sheet, range, .. } => {
require_sheet(model, *sheet)?;
validate_range(*range)?;
validate_range_size(*range)?;
}
Op::AddSheet { index, .. } => {
if *index > model.sheets.len() {
return Err(Error::InvalidOperation(format!(
"sheet index {index} out of range"
)));
}
}
Op::RemoveSheet { index } => {
if *index >= model.sheets.len() {
return Err(Error::InvalidOperation(format!(
"sheet index {index} out of range"
)));
}
}
Op::RenameSheet { sheet, .. } => {
require_sheet(model, *sheet)?;
}
Op::RestoreSheet { .. }
| Op::SetDefinedNames { .. }
| Op::SetCharts { .. }
| Op::RestoreColStyles { .. } => {
return Err(Error::InvalidOperation(
"restore sheet operations are internal".to_string(),
));
}
}
Ok(())
}
fn validate_insert_capacity(model: &WorkbookModel, op: &Op) -> Result<()> {
match *op {
Op::InsertRows {
sheet, at, count, ..
} => {
let sheet = require_sheet(model, sheet)?;
let cutoff = MAX_ROWS - count;
let loses_cells = sheet
.iter_cells()
.any(|(cell, _)| cell.row >= at && cell.row >= cutoff);
let loses_heights = sheet
.row_heights
.keys()
.any(|&row| row >= at && row >= cutoff);
let loses_merges = sheet
.merges
.iter()
.any(|range| range.end.row >= at && range.end.row >= cutoff);
let loses_hyperlinks = sheet
.hyperlinks
.iter()
.any(|link| link.range.end.row >= at && link.range.end.row >= cutoff);
if loses_cells || loses_heights || loses_merges || loses_hyperlinks {
return Err(Error::InvalidOperation(
"row insertion would discard content at the sheet boundary".to_string(),
));
}
refuse_off_grid_chart_anchors(sheet, op, "row")?;
}
Op::InsertCols {
sheet, at, count, ..
} => {
let sheet = require_sheet(model, sheet)?;
let cutoff = MAX_COLS - count;
let loses_cells = sheet
.iter_cells()
.any(|(cell, _)| cell.col >= at && cell.col >= cutoff);
let loses_widths = sheet
.col_widths
.keys()
.any(|&col| col >= at && col >= cutoff);
let loses_merges = sheet
.merges
.iter()
.any(|range| range.end.col >= at && range.end.col >= cutoff);
let loses_hyperlinks = sheet
.hyperlinks
.iter()
.any(|link| link.range.end.col >= at && link.range.end.col >= cutoff);
if loses_cells || loses_widths || loses_merges || loses_hyperlinks {
return Err(Error::InvalidOperation(
"column insertion would discard content at the sheet boundary".to_string(),
));
}
refuse_off_grid_chart_anchors(sheet, op, "column")?;
}
_ => {}
}
Ok(())
}
fn refuse_off_grid_chart_anchors(sheet: &Sheet, op: &Op, axis: &str) -> Result<()> {
if sheet
.charts
.iter()
.all(|chart| insertion_keeps_chart_anchor_on_grid(chart.anchor, op))
{
return Ok(());
}
Err(Error::InvalidOperation(format!(
"{axis} insertion would push a chart anchor past the sheet boundary"
)))
}
fn require_sheet(model: &WorkbookModel, sheet: SheetId) -> Result<&Sheet> {
model.sheet(sheet).ok_or(Error::SheetOutOfRange(sheet))
}
fn validate_sheet_name(name: &str) -> Result<()> {
let invalid = name.is_empty()
|| name.chars().count() > 31
|| name.starts_with('\'')
|| name.ends_with('\'')
|| name
.chars()
.any(|character| matches!(character, ':' | '\\' | '/' | '?' | '*' | '[' | ']'));
if invalid {
return Err(Error::InvalidOperation(format!(
"invalid sheet name: {name:?}"
)));
}
Ok(())
}
fn validate_cell_ref(cell: CellRef) -> Result<()> {
if cell.row >= MAX_ROWS || cell.col >= MAX_COLS {
return Err(Error::CellOutOfRange(cell));
}
Ok(())
}
fn validate_range(range: CellRange) -> Result<()> {
validate_cell_ref(range.start)?;
validate_cell_ref(range.end)?;
if range.start.row > range.end.row || range.start.col > range.end.col {
return Err(Error::InvalidOperation(
"range start must be above and left of range end".to_string(),
));
}
Ok(())
}
fn validate_chart_source(model: &WorkbookModel, has_source_package: bool) -> Result<()> {
if has_source_package || model.sheets.iter().all(|sheet| sheet.charts.is_empty()) {
return Ok(());
}
Err(Error::InvalidOperation(
"charts can only be preserved from a source package, and this workbook has none"
.to_string(),
))
}
fn validate_charts(charts: &[SheetChart]) -> Result<()> {
if charts.len() > MAX_CHARTS_PER_SHEET {
return Err(Error::InvalidOperation(
"sheet contains too many charts".to_string(),
));
}
let mut identities = HashSet::with_capacity(charts.len());
for chart in charts {
if chart.part.is_empty() || chart.drawing.is_empty() {
return Err(Error::InvalidOperation(
"chart must name its part and drawing".to_string(),
));
}
if chart.part.len() > MAX_CHART_FIELD_BYTES
|| chart.drawing.len() > MAX_CHART_FIELD_BYTES
|| chart.refs.len() > MAX_CHART_REFS_PER_CHART
{
return Err(Error::InvalidOperation(
"chart exceeds the supported size".to_string(),
));
}
for path in [&chart.part, &chart.drawing] {
if !is_package_part_path(path) {
return Err(Error::InvalidOperation(format!(
"chart names {path}, which is not a package part path"
)));
}
}
if chart.anchor_index >= MAX_CHART_ANCHORS_PER_DRAWING {
return Err(Error::InvalidOperation(
"chart anchor index is out of range".to_string(),
));
}
if !identities.insert((&chart.drawing, chart.anchor_index)) {
return Err(Error::InvalidOperation(
"two charts claim the same drawing anchor".to_string(),
));
}
validate_chart_anchor(chart.anchor)?;
for reference in &chart.refs {
if reference.formula.len() > MAX_CHART_FIELD_BYTES {
return Err(Error::InvalidOperation(
"chart reference exceeds the supported length".to_string(),
));
}
if !is_writable_xml_text(&reference.formula) {
return Err(Error::InvalidOperation(
"chart reference contains a character xml cannot carry".to_string(),
));
}
}
}
Ok(())
}
fn chart_frame_not_found(frame: &str) -> Error {
Error::InvalidOperation(format!("no chart frame on this sheet is named {frame}"))
}
fn validate_anchor_change(authored: ChartAnchor, moved: ChartAnchor, frame: &str) -> Result<()> {
match (authored, moved) {
(
ChartAnchor::TwoCell { edit_as, .. },
ChartAnchor::TwoCell {
edit_as: moved_edit_as,
..
},
) if edit_as == moved_edit_as => Ok(()),
(
ChartAnchor::OneCell { extent, .. },
ChartAnchor::OneCell {
extent: moved_extent,
..
},
) if extent == moved_extent => Ok(()),
(ChartAnchor::Absolute { .. }, _) | (_, ChartAnchor::Absolute { .. }) => {
Err(Error::InvalidOperation(format!(
"chart {frame} is pinned to the sheet and cannot be moved"
)))
}
(ChartAnchor::TwoCell { .. }, ChartAnchor::TwoCell { .. }) => Err(Error::InvalidOperation(
format!("chart {frame} cannot change how it follows a grid edit"),
)),
(ChartAnchor::OneCell { .. }, ChartAnchor::OneCell { .. }) => Err(Error::InvalidOperation(
format!("chart {frame} cannot be resized: a one-cell extent is not written back"),
)),
_ => Err(Error::InvalidOperation(format!(
"chart {frame} cannot change its anchor kind"
))),
}
}
fn validate_chart_anchor(anchor: ChartAnchor) -> Result<()> {
let cells = match anchor {
ChartAnchor::TwoCell { from, to, .. } => vec![from, to],
ChartAnchor::OneCell { from, .. } => vec![from],
ChartAnchor::Absolute { .. } => Vec::new(),
};
for cell in cells {
if cell.row >= MAX_ROWS || cell.col >= MAX_COLS {
return Err(Error::InvalidOperation(
"chart anchor is off the sheet grid".to_string(),
));
}
}
Ok(())
}
const MAX_ANCHOR_OFFSET_EMU: i64 = 1 << 40;
fn validate_intrinsic_anchor(anchor: ChartAnchor) -> Result<()> {
let offset = |value: i64| {
(0..=MAX_ANCHOR_OFFSET_EMU)
.contains(&value)
.then_some(())
.ok_or_else(|| Error::InvalidOperation("anchor offset is out of range".to_string()))
};
let extent = |value: i64| {
(1..=MAX_ANCHOR_OFFSET_EMU)
.contains(&value)
.then_some(())
.ok_or_else(|| Error::InvalidOperation("anchor extent is not positive".to_string()))
};
match anchor {
ChartAnchor::TwoCell { from, to, .. } => {
for cell in [from, to] {
offset(cell.col_off)?;
offset(cell.row_off)?;
}
if (to.col, to.col_off) <= (from.col, from.col_off)
|| (to.row, to.row_off) <= (from.row, from.row_off)
{
return Err(Error::InvalidOperation(
"anchor corners are inverted or coincident".to_string(),
));
}
}
ChartAnchor::OneCell { from, extent: size } => {
offset(from.col_off)?;
offset(from.row_off)?;
extent(size.cx)?;
extent(size.cy)?;
}
ChartAnchor::Absolute { pos, extent: size } => {
offset(pos.x)?;
offset(pos.y)?;
extent(size.cx)?;
extent(size.cy)?;
}
}
Ok(())
}
fn is_package_part_path(path: &str) -> bool {
!path.starts_with('/')
&& !path.contains('\\')
&& path
.split('/')
.all(|segment| !segment.is_empty() && segment != "." && segment != "..")
&& path.chars().all(|character| {
!character.is_control() && character != '"' && character != '<' && character != '>'
})
}
fn is_writable_xml_text(value: &str) -> bool {
value.chars().all(|character| {
matches!(character, '\t' | '\n' | '\r')
|| (character >= ' ' && character != '\u{fffe}' && character != '\u{ffff}')
})
}
fn validate_hyperlinks(hyperlinks: &[Hyperlink]) -> Result<()> {
if hyperlinks.len() > MAX_HYPERLINKS_PER_SHEET {
return Err(Error::InvalidOperation(
"sheet contains too many hyperlinks".to_string(),
));
}
for hyperlink in hyperlinks {
validate_range(hyperlink.range)?;
if hyperlink
.external_target
.as_deref()
.is_none_or(|value| value.is_empty())
&& hyperlink
.location
.as_deref()
.is_none_or(|value| value.is_empty())
{
return Err(Error::InvalidOperation(
"hyperlink must have a destination".to_string(),
));
}
for value in [
&hyperlink.external_target,
&hyperlink.location,
&hyperlink.tooltip,
&hyperlink.display,
]
.into_iter()
.flatten()
{
if value.len() > MAX_HYPERLINK_FIELD_BYTES {
return Err(Error::InvalidOperation(
"hyperlink field exceeds its length limit".to_string(),
));
}
}
}
Ok(())
}
fn validate_range_size(range: CellRange) -> Result<()> {
let rows = u64::from(range.end.row - range.start.row + 1);
let cols = u64::from(range.end.col - range.start.col + 1);
if rows * cols > MAX_RANGE_CELLS {
return Err(Error::RangeTooLarge {
rows,
cols,
max: MAX_RANGE_CELLS,
});
}
Ok(())
}
fn validate_cell_state(state: &CellState) -> Result<()> {
if matches!(state.value, CellValue::Number { value } if !value.is_finite()) {
return Err(Error::InvalidOperation(
"cell number must be finite".to_string(),
));
}
if matches!(&state.value, CellValue::Text { value } if value.chars().count() > xlsx_calc::eval::MAX_CELL_TEXT_CHARS)
{
return Err(Error::InvalidOperation(
"cell text exceeds Excel's length limit".to_string(),
));
}
if state
.formula
.as_ref()
.is_some_and(|formula| formula.len() > xlsx_calc::lexer::MAX_FORMULA_BYTES)
{
return Err(Error::InvalidOperation(
"formula exceeds the length limit".to_string(),
));
}
Ok(())
}
fn edit_cell_state(
workbook: &WorkbookModel,
sheet: SheetId,
cell: CellRef,
input: &str,
) -> CellState {
let style = workbook
.sheet(sheet)
.and_then(|sheet| sheet.cell(cell))
.and_then(|cell| cell.style);
let number_format = workbook.styles.resolved_format(style).number_format;
let mut state = if !input.is_empty()
&& matches!(
number_format,
FormatCode::Builtin(49) | FormatCode::Custom("@")
) {
CellState {
value: CellValue::Text {
value: input.strip_prefix('\'').unwrap_or(input).to_string(),
},
..Default::default()
}
} else {
cell_state_for_input_no_eval(input)
};
state.style = style;
state
}
fn current_cell_state(workbook: &WorkbookModel, sheet: SheetId, cell: CellRef) -> CellState {
workbook
.sheet(sheet)
.and_then(|sheet| sheet.cell(cell))
.map(CellState::from)
.unwrap_or_default()
}
fn cell_states_semantically_equal(left: &CellState, right: &CellState) -> bool {
match (&left.formula, &right.formula) {
(Some(left_formula), Some(right_formula)) => {
left_formula == right_formula && left.style == right.style
}
_ => left == right,
}
}
fn models_semantically_equal(left: &WorkbookModel, right: &WorkbookModel) -> bool {
if left.date_system != right.date_system
|| left.shared_strings != right.shared_strings
|| left.styles != right.styles
|| left.sheets.len() != right.sheets.len()
{
return false;
}
left.sheets.iter().zip(&right.sheets).all(|(left, right)| {
if left.name != right.name
|| left.merges != right.merges
|| left.col_widths != right.col_widths
|| left.row_heights != right.row_heights
{
return false;
}
let mut left_cells = left.iter_cells();
let mut right_cells = right.iter_cells();
loop {
match (left_cells.next(), right_cells.next()) {
(Some((left_at, left_cell)), Some((right_at, right_cell))) => {
if left_at != right_at
|| !cell_states_semantically_equal(
&CellState::from(left_cell),
&CellState::from(right_cell),
)
{
return false;
}
}
(None, None) => return true,
_ => return false,
}
}
})
}
fn display_text_at(workbook: &WorkbookModel, sheet: SheetId, cell: CellRef) -> Result<String> {
let sheet_ref = workbook.sheet(sheet).ok_or(Error::SheetOutOfRange(sheet))?;
Ok(match sheet_ref.cell(cell) {
Some(cell) => display_text(&workbook.styles, workbook.date_system, cell),
None => String::new(),
})
}
fn contains_lowercased(text: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
if text.is_ascii() {
return needle.is_ascii()
&& text
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
}
if text.contains('Σ') {
return text.to_lowercase().contains(needle);
}
lowered_contains(text, needle)
}
fn lowered_contains(text: &str, needle: &str) -> bool {
let mut needle_chars = needle.chars();
let Some(first) = needle_chars.next() else {
return true;
};
let mut stream = text.chars().flat_map(char::to_lowercase);
while let Some(c) = stream.next() {
if c != first {
continue;
}
let mut rest = stream.clone();
if needle_chars.clone().all(|want| rest.next() == Some(want)) {
return true;
}
}
false
}
fn apply_proposed_number_format(
workbook: &mut WorkbookModel,
sheet: SheetId,
cell: CellRef,
format: &NumberFormatMutation,
) -> Result<()> {
xlsx_ops::apply_in_place(
workbook,
&Op::SetRangeNumberFormat {
sheet,
range: CellRange::new(cell, cell),
format: format.clone(),
},
)?;
Ok(())
}
fn proposal_ghosts(
committed: &WorkbookModel,
preview: &WorkbookModel,
edits: &[ProposedEdit],
) -> Result<Vec<ProposalGhost>> {
let direct: BTreeSet<_> = edits
.iter()
.map(|edit| (edit.sheet, edit.row, edit.col))
.collect();
let mut ghosts = edits
.iter()
.map(|edit| ProposalGhost {
sheet: edit.sheet,
row: edit.row,
col: edit.col,
old_text: edit.old_text.clone(),
new_text: edit.new_text.clone(),
alignment_value: proposal_alignment_value(
committed,
preview,
SheetId(edit.sheet),
CellRef::new(edit.row, edit.col),
),
})
.collect::<Vec<_>>();
for address in changed_cells_between(committed, preview) {
let key = (address.sheet.0, address.cell.row, address.cell.col);
if direct.contains(&key) {
continue;
}
let committed_formula = committed
.sheet(address.sheet)
.and_then(|sheet| sheet.cell(address.cell))
.is_some_and(|cell| cell.formula.is_some());
let preview_formula = preview
.sheet(address.sheet)
.and_then(|sheet| sheet.cell(address.cell))
.is_some_and(|cell| cell.formula.is_some());
if !committed_formula || !preview_formula {
continue;
}
let old_text = display_text_at(committed, address.sheet, address.cell)?;
let new_text = display_text_at(preview, address.sheet, address.cell)?;
if old_text == new_text {
continue;
}
ghosts.push(ProposalGhost {
sheet: address.sheet.0,
row: address.cell.row,
col: address.cell.col,
old_text,
new_text,
alignment_value: proposal_alignment_value(
committed,
preview,
address.sheet,
address.cell,
),
});
}
Ok(ghosts)
}
fn proposal_alignment_value(
committed: &WorkbookModel,
preview: &WorkbookModel,
sheet: SheetId,
cell: CellRef,
) -> CellValue {
let new_value = preview
.sheet(sheet)
.and_then(|sheet| sheet.cell(cell))
.map(|cell| cell.value.clone())
.unwrap_or_default();
if !matches!(new_value, CellValue::Empty) {
return new_value;
}
committed
.sheet(sheet)
.and_then(|sheet| sheet.cell(cell))
.map(|cell| cell.value.clone())
.unwrap_or_default()
}
fn value_to_input(value: &CellValue) -> String {
match value {
CellValue::Empty => String::new(),
CellValue::Number { value } => value.to_string(),
CellValue::Bool { value } => if *value { "TRUE" } else { "FALSE" }.to_string(),
CellValue::Text { value } => {
if !matches!(xlsx_ops::parse_input(value), xlsx_ops::ParsedInput::Text(text) if text == *value)
{
format!("'{value}")
} else {
value.clone()
}
}
CellValue::Error { value } => value.as_str().to_string(),
}
}
fn validate_viewport(viewport: &Viewport) -> Result<()> {
if !viewport.x.is_finite()
|| !viewport.y.is_finite()
|| !viewport.width.is_finite()
|| !viewport.height.is_finite()
|| viewport.width <= 0.0
|| viewport.height <= 0.0
|| !(viewport.x + viewport.width).is_finite()
|| !(viewport.y + viewport.height).is_finite()
{
return Err(Error::InvalidViewport);
}
Ok(())
}
impl Workbook {
fn resolve_chart_space(
&self,
owner: &str,
chart: &SheetChart,
) -> std::result::Result<Arc<ChartSpace>, RenderError> {
let package =
self.source_package
.as_ref()
.ok_or_else(|| RenderError::ChartSourceUnavailable {
part: chart.part.clone(),
})?;
let bytes =
package
.part_bytes(&chart.part)
.ok_or_else(|| RenderError::ChartPartMissing {
part: chart.part.clone(),
})?;
let bytes_hash = chart_bytes_hash(bytes);
let epoch = self.model_epoch;
let key = (chart.part.clone(), owner.to_owned());
let mut cache = self.chart_cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(hit) = cache.get(&key)
&& hit.epoch == epoch
&& hit.bytes_hash == bytes_hash
{
return Ok(hit.space.clone());
}
let space =
xlsx_parse::preserved_chart_space(bytes, &self.model, owner, &self.model.styles.theme)
.ok_or_else(|| RenderError::ChartParseFailed {
part: chart.part.clone(),
})
.map(Arc::new)?;
cache.insert(
key,
CachedChartSpace {
bytes_hash,
epoch,
space: space.clone(),
},
);
Ok(space)
}
}
fn validate_display_region(sheet: &Sheet, styles: &Stylesheet, viewport: &Viewport) -> Result<()> {
validate_viewport(viewport)?;
let geometry = GridGeometry::new(sheet, styles);
let right = viewport.x + viewport.width;
let bottom = viewport.y + viewport.height;
if right > geometry.col_x(MAX_COLS) || bottom > geometry.row_y(MAX_ROWS) {
return Err(Error::InvalidViewport);
}
let (rows, columns) = geometry.viewport_range(viewport);
let (frozen_rows, frozen_cols) = sheet
.freeze_pane
.map_or((0, 0), |pane| (pane.rows, pane.cols));
let row_count = u64::from(rows.end - rows.start).saturating_add(u64::from(frozen_rows));
let column_count =
u64::from(columns.end - columns.start).saturating_add(u64::from(frozen_cols));
let cells = row_count.saturating_mul(column_count);
if cells > MAX_DISPLAY_CELLS {
return Err(Error::DisplayTooLarge {
cells,
max: MAX_DISPLAY_CELLS,
});
}
Ok(())
}
#[cfg(feature = "raster")]
fn renderable(sheet: &Sheet, styles: &Stylesheet, viewport: &Viewport, scale: f32) -> bool {
let width = ((viewport.width * scale).ceil() as u32).max(1);
let height = ((viewport.height * scale).ceil() as u32).max(1);
validate_render_size(width, height).is_ok()
&& validate_display_region(sheet, styles, viewport).is_ok()
}
#[cfg(feature = "raster")]
fn validate_render_size(width: u32, height: u32) -> Result<()> {
if width > MAX_PIXMAP_DIM || height > MAX_PIXMAP_DIM {
return Err(Error::RenderTooLarge {
width,
height,
max: MAX_PIXMAP_DIM,
});
}
if u64::from(width) * u64::from(height) > MAX_PIXMAP_PIXELS {
return Err(Error::RenderAreaTooLarge {
width,
height,
max_pixels: MAX_PIXMAP_PIXELS,
});
}
Ok(())
}
fn validate_axis(axis: &str, at: u32, count: u32, limit: u32) -> Result<()> {
if count == 0 {
return Err(Error::InvalidOperation(format!(
"{axis} operation count must be positive"
)));
}
if at >= limit || count > limit - at {
return Err(Error::InvalidOperation(format!(
"{axis} operation exceeds sheet bounds"
)));
}
Ok(())
}
fn rename_sheet_view(names: &mut Vec<String>, op: &Op) {
match op {
Op::AddSheet { index, name } => names.insert((*index).min(names.len()), name.clone()),
Op::RemoveSheet { index } if *index < names.len() => {
names.remove(*index);
}
Op::RenameSheet { sheet, name } | Op::RestoreSheet { sheet, name, .. } => {
if let Some(slot) = names.get_mut(sheet.0 as usize) {
*slot = name.clone();
}
}
_ => {}
}
}
fn invalidates_proposals(op: &Op) -> bool {
matches!(
op,
Op::InsertRows { .. }
| Op::DeleteRows { .. }
| Op::InsertCols { .. }
| Op::DeleteCols { .. }
| Op::SetHyperlinks { .. }
| Op::RestoreColStyles { .. }
| Op::SetCharts { .. }
| Op::AddSheet { .. }
| Op::RemoveSheet { .. }
| Op::RenameSheet { .. }
| Op::RestoreSheet { .. }
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contains_lowercased_matches_std_lowercase_semantics() {
let texts = [
"Hello World",
"ALPHA",
"alpha",
"aaa",
"25.00%",
"",
"İSTANBUL",
"İ",
"i̇",
"STRİNG THEORY",
"ΟΣ",
"ΑΣΑ",
"ΣΟΦΟΣ",
"ὈΣΟΣ",
"AΣ'Σ",
"Σ",
"ΣA",
"A Σ. Σ",
"Κ",
"10Κ run",
"Džungla",
"ẞtraße",
"ffile",
"mixed ΣΩΕΛτα İcl",
"ΕΛΛΗΝΙΚΆ",
"ΟΔΟΣ ΚΑΙ ΣΤΑΘΜΟΣ",
];
let queries = [
"hello",
"ALPHA",
"world",
"aa",
"aaa",
"5.00%",
"i",
"i̇",
"̇",
"İ",
"İstanbul",
"string",
"ος",
"οσ",
"ασα",
"σοφοσ",
"ς",
"σ",
"ς.",
"σ ς",
"κ",
"10κ",
"ungla",
"džungla",
"strasse",
"straße",
"ffi",
"file",
"ελληνικά",
"Σ",
"σταθμοσ",
"a",
"z",
"",
];
for text in texts {
for query in queries {
let needle = query.to_lowercase();
let expected = text.to_lowercase().contains(&needle);
let actual = contains_lowercased(text, &needle);
assert_eq!(actual, expected, "text={text:?} query={query:?}");
}
}
}
#[test]
fn contains_lowercased_handles_edge_cases() {
assert!(!contains_lowercased("Hello World", "wörld"));
assert!(contains_lowercased("10\u{212a} run", "10k"));
assert!(contains_lowercased("ΟΔΟΣ", "ο\u{3c2}"));
assert!(!contains_lowercased("ΟΔΟΣ", "ο\u{3c3}"));
}
}