@a3s-lab/office 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -309,6 +309,21 @@ and [CLI reference](./docs/latest/en/cli-reference.md).
309
309
 
310
310
  ## Current release
311
311
 
312
+ Version `0.43.0` makes Spreadsheet formula conditional formatting a local,
313
+ editable workflow instead of a file-only preservation path:
314
+
315
+ - **Home → Conditional Formatting → Custom formula** authors rules with
316
+ relative/absolute references, finite cross-sheet references, independent
317
+ text/fill colors, ordered precedence, and Stop-if-true behavior. Imported
318
+ XLSX expression rules reopen in the same editor.
319
+ - The shared synchronous evaluator reads only cached workbook values, caps
320
+ formulas at 255 Unicode characters and each decision at 1,024 cells, scans
321
+ bounded blank ranges without densifying the workbook, and fails closed for
322
+ external, whole-row/column, missing-sheet, or uncached-formula references.
323
+ - Native XLSX differential styles, `sqref`, priorities, and formulas round-trip
324
+ through export/reopen. The public **新建 → 公式条件格式** template shows a
325
+ blocking rule and a cross-sheet threshold without any remote service.
326
+
312
327
  Version `0.42.0` extends Spreadsheet data validation with a local custom-formula
313
328
  workflow that remains bounded, inspectable, and native-file compatible:
314
329
 
@@ -3,14 +3,14 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { Workbook } from "@fortune-sheet/react";
4
4
  import { AArrowDown, AArrowUp, AlignCenter, AlignLeft, AlignRight, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, ArrowDown, ArrowDownToLine, ArrowLeft, ArrowLeftToLine, ArrowRight, ArrowRightToLine, ArrowUp, ArrowUpToLine, BadgeJapaneseYen, BarChart3, BetweenHorizontalEnd, BetweenHorizontalStart, BetweenVerticalEnd, BetweenVerticalStart, Bold, Bookmark, Calculator, CalendarClock, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, ClipboardPaste, Clock, Cloud, Columns3, Copy, CopyPlus, DecimalsArrowLeft, DecimalsArrowRight, Eraser, Eye, EyeOff, FileX2, Globe2, Grid2X2, Grid3X3, Hash, Info, Italic, KeyRound, Layers2, Link2, Link2Off, ListChecks, ListFilter, ListOrdered, ListPlus, LocateFixed, Merge, MessageSquareX, MoveHorizontal, MoveVertical, Paintbrush, Palette, PanelLeft, PanelTop, PanelsTopLeft, Pencil, Percent, Plus, Printer, Redo2, RefreshCw, Rows3, Save, Scissors, Search, Settings2, ShieldCheck, Sigma, SlidersHorizontal, SortAsc, SortDesc, Strikethrough, SwatchBook, Table2, TableProperties, Trash2, TriangleAlert, Underline, Undo2, WrapText, X } from "lucide-react";
5
5
  import { Fragment as external_react_Fragment, cloneElement, createElement, memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
6
- import { convertSpanToShareString, genarate, indexToColumnChar, update as core_update } from "@fortune-sheet/core";
7
6
  import { Parser, SUPPORTED_FORMULAS } from "@fortune-sheet/formula-parser";
7
+ import { convertSpanToShareString, genarate, indexToColumnChar, update as core_update } from "@fortune-sheet/core";
8
+ import { useOfficeTaskPaneEscape, OfficeCheckbox, useOfficeDialog, CommittedOfficeNumberField, useOfficeTaskPaneModal, createOfficeKernelClient, officeFontFamilies, CollectionState, OfficeNumberField, handleOfficeTaskPaneKeyDown, normalizeFormulaForFortuneParser } from "./0~7048.js";
8
9
  import { freezeImportedSpreadsheetCell, normalizeXlsxSemanticColorOrigin, spreadsheetMatrixProfile, withXlsxPatternFill, withoutEditableRange, normalizeSpreadsheetConditionalVisualOptions, xlsxSemanticColorMatchesValue, withXlsxGradientFill, effectiveSpreadsheetPageSetup, attachSpreadsheetShownCommentCells, normalizeXlsxPatternFill, withEditableRange, sameSpreadsheetHistoryValue, isSpreadsheetConditionalComparisonOperator, spreadsheetConditionalIconForValue, defaultSpreadsheetConditionalIconThresholds, xlsxPatternFillTypes, sheetProtectionAuthority, normalizeXlsxGradientFill, xlsxGradientFillFallbackColor, editableRangeCellCount, unlockedCellCount, registerDerivedSpreadsheetMatrix, editableRangeRequiresCredentials, SPREADSHEET_CONDITIONAL_COMPARISON_OPERATORS, normalizeSpreadsheetConditionalIconSetFormat, defaultSpreadsheetDataBarOptions, spreadsheetConditionalThresholdValue, normalizeSheetProtectionAuthority, withSheetProtection, spreadsheetProtectionKey, defaultSpreadsheetColorScaleThresholds, protectedSheetCount, SPREADSHEET_CONDITIONAL_ICON_SETS, withSheetSelectionPermissions, spreadsheetConditionalComparisonNeedsUpperValue, drawSpreadsheetConditionalIcon, isSpreadsheetConditionalIconSetName, spreadsheetConditionalThresholdsEqual, spreadsheetConditionalIconSetCount, spreadsheetConditionalIconAppearance } from "./9333.js";
9
10
  import { workSpreadsheetChartSupportsGrouping, spreadsheetFormulaRangesForSelection, createSpreadsheetChartFromSelection, normalizeWorkSpreadsheetDataLabelPosition, normalizeSpreadsheetPrintTitleColumns, normalizeSpreadsheetPrintTitleRows, formulaHasStructuredReference, normalizeWorkSpreadsheetErrorBars, defaultPivotValueCaption, workSpreadsheetChartSupportsBarSpacing, normalizeWorkSpreadsheetChartGrouping, spreadsheetGridSize, normalizeWorkSpreadsheetChartAxisGroup, normalizeWorkSpreadsheetScatterStyle, cloneSparseMatrix, spreadsheetPivotAggregationLabel, workSpreadsheetChartAxisIsCategoryAxis, reconcileSpreadsheetChartPreviews, normalizeWorkSpreadsheetChartAxes, normalizeWorkSpreadsheetDoughnutHoleSize, normalizeWorkSpreadsheetChartLegendPosition, spreadsheetChartCount, workSpreadsheetChartTypeLabel, sparseArrayEntries, normalizeWorkSpreadsheetTrendline, effectiveSpreadsheetCalculationSettings, spreadsheetPivotIntersects, normalizeWorkSpreadsheetDataLabels, spreadsheetPivotOutputContains, normalizeSpreadsheetPrintArea, spreadsheetCellAddress, formatSpreadsheetCellRanges, normalizeWorkSpreadsheetChartLayout, spreadsheetFormulaRangeForCell, workSpreadsheetChartAxisIsValueAxis, normalizeWorkSpreadsheetChartSmoothLines, spreadsheetPivotFilterValueKey, normalizeWorkSpreadsheetBubbleScale, normalizeWorkSpreadsheetChartSeriesStyle, volatileSpreadsheetFormulaFunctions, sparseArrayIndexes, normalizeWorkSpreadsheetBubbleSizeRepresents, workSpreadsheetChartSupportsTrendlines, normalizeWorkSpreadsheetChartLegendOverlay, parseSpreadsheetChartReference, workSpreadsheetChartGroupingIsStacked, normalizeWorkSpreadsheetChartGapWidth, workSpreadsheetChartSupportsSmoothLines, refreshSpreadsheetPivotTables, createSpreadsheetPivotFromSelection, reconcileSpreadsheetPivots, spreadsheetPivotCount, sparseMatrixColumnCount, workSpreadsheetChartSupportsErrorBars, formulaHasExternalReference, isValidSpreadsheetDefinedName, spreadsheetPivotValidation, deleteSpreadsheetPivotTable, workSpreadsheetChartAxisDefaultLabelPosition, workSpreadsheetChartSupportsSeriesAnalysis, parseSpreadsheetCellRanges, spreadsheetPivotFilterItems, spreadsheetPivotFields, normalizeWorkSpreadsheetChartOverlap, spreadsheetFormulaRangeConflict, spreadsheetSheetsWithChartPreviews, workSpreadsheetChartUsesNumericXAxis, workSpreadsheetChartAxisShowsMajorGridlinesByDefault, workSpreadsheetChartSupportsAxes, normalizeWorkSpreadsheetCombinationSeriesType, normalizeWorkSpreadsheetRadarStyle } from "./8715.js";
10
11
  import { workSpreadsheetFilterHiddenRows, xlsxRichTextCellText, spreadsheetTextOrientationFromAngle, spreadsheetCellValueWithDiagonalBorder, spreadsheetUnderlineCellValue, workSpreadsheetAutoFilterCriteriaEntries, spreadsheetTableTotalsFunctionLabel, withDefaultSpreadsheetTableTotalsLabel, normalizeXlsxRichTextEditSource, spreadsheetTableTotalsColumnPatch, SPREADSHEET_TABLE_TOTALS_FUNCTIONS, normalizeXlsxRichTextCell, activeXlsxNativeFill, normalizeSpreadsheetDateValidationBoundary, spreadsheetTextOrientationChoiceFromCell, parseSpreadsheetSortCustomList, workSpreadsheetFilterTextCharacters, sameXlsxRichTextRunStyle, spreadsheetSortCustomListMatchKey, createSpreadsheetSortCustomList, reconcileSpreadsheetTableTotalsColumns, spreadsheetTextOrientationFromCell, patchSpreadsheetRichTextFontRuns, synchronizeSpreadsheetTableTotalsRow, workSpreadsheetAutoFilterManuallyHiddenRows, normalizedWorkSpreadsheetAutoFilterRange, workSpreadsheetSheetWithoutAutoFilterCriteria, isSpreadsheetTextOrientationId, reconcileSpreadsheetTableCalculatedColumns, spreadsheetVisibleTextRotationFromCell, spreadsheetTextOrientationFromChoice, normalizeWorkSpreadsheetFilterCriteria, readOfficeSpreadsheetCollaboration as readWorkOfficeSpreadsheetCollaboration, normalizeSpreadsheetDataValidationErrorStyle, workSpreadsheetAutoFilterCriteria, normalizeXlsxRichTextColor, isHighSurrogate, xlsxNativeFillCellKeys, workSpreadsheetSheetWithReappliedAutoFilterCriteria, workSpreadsheetSheetWithAutoFilterCriteria, createOfficeSpreadsheetCollaborationBinding as createWorkOfficeSpreadsheetCollaborationBinding, boundedSpreadsheetDataValidationText, spreadsheetUnderlineStyle, workSpreadsheetCellIsDate, workSpreadsheetAutoFilterOwnedRows, deleteXlsxNativeFills, spreadsheetTextOrientationCellStyle, normalizeXlsxRichTextRun, spreadsheetDiagonalBorderFromCellValue, isLowSurrogate, mergeSpreadsheetSortCustomLists, spreadsheetTableTotalsRowHasContent, validateSpreadsheetSortCustomList, normalizeStoredSpreadsheetSortCustomLists, coalesceXlsxRichTextRuns, fillSpreadsheetTableCalculatedColumns, replaceOfficeSpreadsheetCollaboration as replaceWorkOfficeSpreadsheetCollaboration, validXlsxRichText, spreadsheetSortCustomListsEqual, isSpreadsheetUnderlineStyle } from "./5416.js";
11
12
  import { moveOfficeMenuFocus, stepOfficeZoom, useOfficeRemoteParticipants, Popover, Dialog, officePresenceColor, useOfficePublishPresenceLocation, button_Button, icon_button_IconButton, moveOfficeGridMenuFocus, office_text_field_OfficeTextField, useDialogFocusScope, useOfficeEditorWheelZoom } from "./0~8136.js";
12
13
  import { isOfficeKernelSpreadsheetError, normalizeCssColor, OFFICE_KERNEL_SPREADSHEET_MAX_ROWS, createOfficeId as createWorkId } from "./4121.js";
13
- import { useOfficeTaskPaneEscape, OfficeCheckbox, CommittedOfficeNumberField, useOfficeDialog, useOfficeTaskPaneModal, createOfficeKernelClient, officeFontFamilies, CollectionState, OfficeNumberField, handleOfficeTaskPaneKeyDown, normalizeFormulaForFortuneParser } from "./0~7048.js";
14
14
  import { isOfficeShortcutBlocked, OfficeColorPicker } from "./0~3635.js";
15
15
  import { createOfficeEditorExtension, useOfficeEditorRuntime, useOfficeEditorKeyboardShortcuts } from "./0~3557.js";
16
16
  import { showToast } from "./6489.js";
@@ -159,30 +159,187 @@ function displayLength(valueRatio, minLength, maxLength) {
159
159
  const normalized = Math.max(0, Math.min(1, valueRatio));
160
160
  return minLength + (maxLength - minLength) * normalized;
161
161
  }
162
- function spreadsheetConditionalFormatStyles(sheet) {
162
+ const MAX_SPREADSHEET_LOCAL_FORMULA_LENGTH = 255;
163
+ const work_spreadsheet_local_formula_MAX_SPREADSHEET_LOCAL_FORMULA_REFERENCED_CELLS = 1024;
164
+ const MAX_SHEET_ROW = 1048576;
165
+ const MAX_SHEET_COLUMN = 16384;
166
+ const LOCAL_FORMULA_FUNCTIONS = new Set(SUPPORTED_FORMULAS.map((name)=>name.toUpperCase()));
167
+ function evaluateSpreadsheetLocalFormula(formula, options) {
168
+ const source = formula.trim().replace(/^=/, '').trim();
169
+ if (!source) return unsupportedLocalFormula('公式为空。');
170
+ if (Array.from(source).length > MAX_SPREADSHEET_LOCAL_FORMULA_LENGTH) return unsupportedLocalFormula('公式超过 255 个字符。');
171
+ const currentSheet = options.sheets.find((candidate)=>candidate.id === options.sheetId);
172
+ if (!currentSheet) return unsupportedLocalFormula('找不到公式所在的工作表。');
173
+ const evaluator = new SpreadsheetLocalFormulaEvaluator(options, currentSheet);
174
+ return evaluator.evaluate(source);
175
+ }
176
+ function spreadsheetLocalFormulaBoolean(value) {
177
+ if ('boolean' == typeof value) return value;
178
+ if ('number' == typeof value) return Number.isFinite(value) && 0 !== value;
179
+ return 'string' == typeof value && 'TRUE' === value.trim().toUpperCase();
180
+ }
181
+ class SpreadsheetLocalFormulaEvaluator {
182
+ options;
183
+ currentSheet;
184
+ sheetsByName = new Map();
185
+ anchor;
186
+ referencedCells = 0;
187
+ unsupportedMessage;
188
+ celldataBySheet = new Map();
189
+ constructor(options, currentSheet){
190
+ this.options = options;
191
+ this.currentSheet = currentSheet;
192
+ this.anchor = {
193
+ row: options.anchorRow ?? options.row,
194
+ column: options.anchorColumn ?? options.column
195
+ };
196
+ for (const sheet of options.sheets)this.sheetsByName.set(sheet.name.trim().toLocaleLowerCase(), sheet);
197
+ }
198
+ evaluate(formula) {
199
+ const parser = new Parser();
200
+ parser.setFunction('ROW', (parameters)=>parameters.length ? null : this.options.row + 1).setFunction('COLUMN', (parameters)=>parameters.length ? null : this.options.column + 1);
201
+ let unsupportedFunction;
202
+ parser.on('callFunction', (name)=>{
203
+ const normalized = name.toUpperCase();
204
+ if (!LOCAL_FORMULA_FUNCTIONS.has(normalized)) unsupportedFunction ??= normalized;
205
+ });
206
+ parser.on('callCellValue', (coordinate, _parserOptions, done)=>{
207
+ const resolved = this.resolveCoordinate(this.currentSheet, coordinate);
208
+ if (!resolved) return void done(null);
209
+ done(this.readCell(resolved));
210
+ });
211
+ parser.on('callRangeValue', (start, end, _parserOptions, done)=>{
212
+ done(this.readRange(start, end));
213
+ });
214
+ const parsed = parser.parse(normalizeFormulaForFortuneParser(formula), {
215
+ sheetId: this.currentSheet.id
216
+ });
217
+ if (unsupportedFunction) return unsupportedLocalFormula(`公式函数“${unsupportedFunction}”无法在浏览器本地求值。`);
218
+ if (this.unsupportedMessage) return unsupportedLocalFormula(this.unsupportedMessage);
219
+ if (parsed.error || void 0 === parsed.result) return unsupportedLocalFormula('公式无法求值,请检查函数、引用和括号。');
220
+ return {
221
+ supported: true,
222
+ value: parsed.result
223
+ };
224
+ }
225
+ resolveCoordinate(fallbackSheet, coordinate) {
226
+ const sheet = coordinate.sheetName ? this.sheetsByName.get(coordinate.sheetName.trim().toLocaleLowerCase()) : fallbackSheet;
227
+ if (!sheet) {
228
+ this.unsupportedMessage = '公式引用了不存在的工作表。';
229
+ return null;
230
+ }
231
+ if (coordinate.row.index < 0 || coordinate.column.index < 0) {
232
+ this.unsupportedMessage = '公式暂不支持整行或整列引用,请改用有限区域。';
233
+ return null;
234
+ }
235
+ const row = coordinate.row.isAbsolute ? coordinate.row.index : this.options.row + (coordinate.row.index - this.anchor.row);
236
+ const column = coordinate.column.isAbsolute ? coordinate.column.index : this.options.column + (coordinate.column.index - this.anchor.column);
237
+ if (row < 0 || column < 0 || row >= MAX_SHEET_ROW || column >= MAX_SHEET_COLUMN) {
238
+ this.unsupportedMessage = '公式引用超出了工作表边界。';
239
+ return null;
240
+ }
241
+ return {
242
+ sheet,
243
+ row,
244
+ column
245
+ };
246
+ }
247
+ readRange(start, end) {
248
+ const startCoordinate = this.resolveCoordinate(this.currentSheet, start);
249
+ const rangeSheet = start.sheetName ? this.sheetsByName.get(start.sheetName.trim().toLocaleLowerCase()) : this.currentSheet;
250
+ const endCoordinate = rangeSheet ? this.resolveCoordinate(rangeSheet, end) : null;
251
+ if (!startCoordinate || !endCoordinate || startCoordinate.sheet.id !== endCoordinate.sheet.id) {
252
+ this.unsupportedMessage ??= '公式区域引用必须位于同一工作表。';
253
+ return [];
254
+ }
255
+ const rowStart = Math.min(startCoordinate.row, endCoordinate.row);
256
+ const rowEnd = Math.max(startCoordinate.row, endCoordinate.row);
257
+ const columnStart = Math.min(startCoordinate.column, endCoordinate.column);
258
+ const columnEnd = Math.max(startCoordinate.column, endCoordinate.column);
259
+ const area = (rowEnd - rowStart + 1) * (columnEnd - columnStart + 1);
260
+ if (area <= 0 || area > work_spreadsheet_local_formula_MAX_SPREADSHEET_LOCAL_FORMULA_REFERENCED_CELLS - this.referencedCells) {
261
+ this.unsupportedMessage = '公式一次最多读取 1,024 个单元格。';
262
+ return [];
263
+ }
264
+ const values = [];
265
+ for(let row = rowStart; row <= rowEnd; row += 1){
266
+ const valuesRow = [];
267
+ for(let column = columnStart; column <= columnEnd; column += 1)valuesRow.push(this.readCell({
268
+ sheet: startCoordinate.sheet,
269
+ row,
270
+ column
271
+ }));
272
+ values.push(valuesRow);
273
+ }
274
+ return values;
275
+ }
276
+ readCell(coordinate) {
277
+ this.referencedCells += 1;
278
+ if (this.referencedCells > work_spreadsheet_local_formula_MAX_SPREADSHEET_LOCAL_FORMULA_REFERENCED_CELLS) {
279
+ this.unsupportedMessage = '公式一次最多读取 1,024 个单元格。';
280
+ return null;
281
+ }
282
+ if (this.options.useProposedValue && coordinate.sheet.id === this.currentSheet.id && coordinate.row === this.options.row && coordinate.column === this.options.column) return this.options.proposedValue;
283
+ const cell = this.cellAt(coordinate.sheet, coordinate.row, coordinate.column);
284
+ if (!cell) return null;
285
+ if ('string' == typeof cell.f && cell.f.length > 0 && void 0 === cell.v) {
286
+ this.unsupportedMessage ??= '公式引用了尚未计算的公式单元格。';
287
+ return null;
288
+ }
289
+ return cell.v ?? null;
290
+ }
291
+ cellAt(sheet, row, column) {
292
+ if (void 0 !== sheet.data) return sheet.data[row]?.[column] ?? null;
293
+ let cells = this.celldataBySheet.get(sheet.id);
294
+ if (!cells) {
295
+ cells = new Map();
296
+ for (const entry of sheet.celldata ?? []){
297
+ const value = entry.v && 'object' == typeof entry.v && 'v' in entry.v ? entry.v : {
298
+ v: entry.v
299
+ };
300
+ cells.set(`${entry.r}_${entry.c}`, value);
301
+ }
302
+ this.celldataBySheet.set(sheet.id, cells);
303
+ }
304
+ return cells.get(`${row}_${column}`) ?? null;
305
+ }
306
+ }
307
+ function unsupportedLocalFormula(message) {
308
+ return {
309
+ supported: false,
310
+ value: false,
311
+ message
312
+ };
313
+ }
314
+ const MAX_SPREADSHEET_CONDITIONAL_FORMULA_SCAN_CELLS = 10000;
315
+ function spreadsheetConditionalFormatStyles(sheet, formulaSheets) {
163
316
  const styles = new Map();
164
317
  const data = sheet.data ?? [];
165
- if (!data.length) return styles;
318
+ const rules = conditionalRules(sheet);
319
+ if (!data.length && !rules.some(isFormulaConditionalRule)) return styles;
166
320
  const blocked = new Set();
167
- for (const rule of conditionalRules(sheet)){
321
+ for (const rule of rules){
168
322
  const matched = new Set();
169
323
  if ('colorGradation' === rule.type) applyColorScale(styles, data, rule, blocked, matched);
170
324
  else if ('dataBar' === rule.type) applyDataBars(styles, data, rule, blocked, matched);
171
325
  else if ('icons' === rule.type) applyIconSet(styles, data, rule, blocked, matched);
172
- else applyDefaultRule(styles, data, rule, blocked, matched);
326
+ else applyDefaultRule(styles, data, rule, blocked, matched, formulaSheets, sheet);
173
327
  if (rule.stopIfTrue) for (const key of matched)blocked.add(key);
174
328
  }
175
329
  return styles;
176
330
  }
177
- function applyDefaultRule(styles, data, rule, blocked, matched) {
331
+ function isFormulaConditionalRule(rule) {
332
+ return 'default' === rule.type && 'formula' === rule.conditionName;
333
+ }
334
+ function applyDefaultRule(styles, data, rule, blocked, matched, formulaSheets, currentSheet) {
178
335
  if ('default' !== rule.type || !isConditionalFormatStyle(rule.format) || !rule.conditionName) return;
179
336
  const values = (rule.conditionValue ?? []).map(String);
180
337
  const style = rule.format;
181
338
  if ('formula' === rule.conditionName && values[0]) {
182
- for (const range of rule.cellrange)forEachCell(data, [
339
+ for (const range of rule.cellrange)forEachFormulaCell(data, [
183
340
  range
184
341
  ], ({ row, column })=>{
185
- if (evaluateFormula(values[0], row, column, range, data)) applyDifferentialStyle(styles, blocked, matched, row, column, style);
342
+ if (evaluateFormula(values[0], row, column, range, currentSheet, formulaSheets)) applyDifferentialStyle(styles, blocked, matched, row, column, style);
186
343
  });
187
344
  return;
188
345
  }
@@ -361,35 +518,20 @@ function matchesCondition(value, name, conditions) {
361
518
  }
362
519
  return false;
363
520
  }
364
- function evaluateFormula(source, row, column, baseRange, data) {
365
- const formula = source.trim().replace(/^=/, '').replace(/\s+/g, '');
366
- if (/^TRUE\(\)?$/i.test(formula)) return true;
367
- if (/^FALSE\(\)?$/i.test(formula)) return false;
368
- const parity = /^MOD\((ROW|COLUMN)\(\),(\d+)\)(=|<>|>=|<=|>|<)(-?\d+)$/i.exec(formula);
369
- if (parity) {
370
- const value = 'ROW' === parity[1].toUpperCase() ? row + 1 : column + 1;
371
- return compareWithOperator(value % Number(parity[2]), parity[3], Number(parity[4]));
372
- }
373
- const blank = /^(NOT\()?ISBLANK\((\$?)([A-Z]{1,3})(\$?)([1-9]\d*)\)\)?$/i.exec(formula);
374
- if (blank) {
375
- const target = formulaReference(blank[2], blank[3], blank[4], blank[5], row, column, baseRange);
376
- const empty = null == work_spreadsheet_conditional_format_cellValue(data, target.row, target.column) || '' === work_spreadsheet_conditional_format_cellValue(data, target.row, target.column);
377
- return blank[1] ? !empty : empty;
378
- }
379
- const comparison = /^(\$?)([A-Z]{1,3})(\$?)([1-9]\d*)(=|<>|>=|<=|>|<)(?:"((?:[^"]|"")*)"|(-?\d+(?:\.\d+)?))$/i.exec(formula);
380
- if (!comparison) return false;
381
- const target = formulaReference(comparison[1], comparison[2], comparison[3], comparison[4], row, column, baseRange);
382
- const left = work_spreadsheet_conditional_format_cellValue(data, target.row, target.column);
383
- const right = void 0 !== comparison[6] ? comparison[6].replaceAll('""', '"') : Number(comparison[7]);
384
- return compareWithOperator(left, comparison[5], right);
385
- }
386
- function formulaReference(absoluteColumn, columnName, absoluteRow, rowNumber, row, column, baseRange) {
387
- const sourceColumn = decodeColumn(columnName);
388
- const sourceRow = Number(rowNumber) - 1;
389
- return {
390
- row: absoluteRow ? sourceRow : sourceRow + row - baseRange.row[0],
391
- column: absoluteColumn ? sourceColumn : sourceColumn + column - baseRange.column[0]
392
- };
521
+ function evaluateFormula(source, row, column, baseRange, currentSheet, formulaSheets) {
522
+ const sheets = [
523
+ toFormulaSheet(currentSheet),
524
+ ...(formulaSheets ?? []).filter((sheet)=>sheet.id && sheet.id !== currentSheet.id).map(toFormulaSheet)
525
+ ];
526
+ const result = evaluateSpreadsheetLocalFormula(source, {
527
+ sheets,
528
+ sheetId: currentSheet.id ?? '',
529
+ row,
530
+ column,
531
+ anchorRow: baseRange.row[0],
532
+ anchorColumn: baseRange.column[0]
533
+ });
534
+ return result.supported && spreadsheetLocalFormulaBoolean(result.value);
393
535
  }
394
536
  function applyDifferentialStyle(styles, blocked, matched, row, column, style) {
395
537
  const patch = {};
@@ -448,6 +590,21 @@ function forEachCell(data, ranges, visit) {
448
590
  column
449
591
  });
450
592
  }
593
+ function forEachFormulaCell(data, ranges, visit) {
594
+ for (const range of ranges){
595
+ const area = (range.row[1] - range.row[0] + 1) * (range.column[1] - range.column[0] + 1);
596
+ if (area > 0 && area <= MAX_SPREADSHEET_CONDITIONAL_FORMULA_SCAN_CELLS) {
597
+ for(let row = range.row[0]; row <= range.row[1]; row += 1)for(let column = range.column[0]; column <= range.column[1]; column += 1)visit({
598
+ row,
599
+ column
600
+ });
601
+ continue;
602
+ }
603
+ forEachCell(data, [
604
+ range
605
+ ], visit);
606
+ }
607
+ }
451
608
  function work_spreadsheet_conditional_format_cellValue(data, row, column) {
452
609
  return data[row]?.[column]?.v ?? null;
453
610
  }
@@ -464,19 +621,13 @@ function compareValues(left, right) {
464
621
  if (String(left).trim() && String(right).trim() && Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return leftNumber === rightNumber ? 0 : leftNumber > rightNumber ? 1 : -1;
465
622
  return String(left).localeCompare(String(right));
466
623
  }
467
- function compareWithOperator(left, operator, right) {
468
- const comparison = compareValues(left, right);
469
- if ('=' === operator) return 0 === comparison;
470
- if ('<>' === operator) return 0 !== comparison;
471
- if ('>' === operator) return comparison > 0;
472
- if ('<' === operator) return comparison < 0;
473
- if ('>=' === operator) return comparison >= 0;
474
- return '<=' === operator && comparison <= 0;
475
- }
476
- function decodeColumn(value) {
477
- let result = 0;
478
- for (const character of value.toUpperCase())result = 26 * result + character.charCodeAt(0) - 64;
479
- return result - 1;
624
+ function toFormulaSheet(sheet) {
625
+ return {
626
+ id: sheet.id ?? '',
627
+ name: sheet.name,
628
+ data: sheet.data,
629
+ celldata: sheet.celldata
630
+ };
480
631
  }
481
632
  function drawSpreadsheetDiagonalDownBorder(context, bounds, line) {
482
633
  drawSpreadsheetDiagonalBorder(context, bounds, line, 'down');
@@ -13073,6 +13224,11 @@ function newConditionalRuleDraft(sheetId) {
13073
13224
  comparisonCellColor: DEFAULT_COLORS.comparisonCell,
13074
13225
  comparisonUseTextColor: true,
13075
13226
  comparisonUseCellColor: true,
13227
+ formula: '=A1>0',
13228
+ formulaTextColor: DEFAULT_COLORS.comparisonText,
13229
+ formulaCellColor: DEFAULT_COLORS.comparisonCell,
13230
+ formulaUseTextColor: true,
13231
+ formulaUseCellColor: true,
13076
13232
  scaleSize: '3',
13077
13233
  minimumColor: DEFAULT_COLORS.minimum,
13078
13234
  midpointColor: DEFAULT_COLORS.midpoint,
@@ -13104,7 +13260,7 @@ function conditionalRuleDraftForRule(sheetId, rule) {
13104
13260
  draft.comparisonUseCellColor = Boolean(style.cellColor);
13105
13261
  draft.comparisonTextColor = colorInputValue(style.textColor, DEFAULT_COLORS.comparisonText);
13106
13262
  draft.comparisonCellColor = colorInputValue(style.cellColor, DEFAULT_COLORS.comparisonCell);
13107
- } else if (isToolbarConditionalRule(rule)) {
13263
+ } else if ('default' !== rule.type || 'formula' !== rule.conditionName || Array.isArray(rule.format)) if (isToolbarConditionalRule(rule)) {
13108
13264
  draft.type = 'toolbarRule';
13109
13265
  draft.preservedRule = rule;
13110
13266
  } else if ('colorGradation' === rule.type && Array.isArray(rule.format)) {
@@ -13139,6 +13295,16 @@ function conditionalRuleDraftForRule(sheetId, rule) {
13139
13295
  draft.barMaxLength = String(options.maxLength ?? 90);
13140
13296
  }
13141
13297
  }
13298
+ else {
13299
+ const style = rule.format;
13300
+ const values = (rule.conditionValue ?? []).map(String);
13301
+ draft.type = 'formula';
13302
+ draft.formula = values[0] ?? '';
13303
+ draft.formulaUseTextColor = Boolean(style.textColor);
13304
+ draft.formulaUseCellColor = Boolean(style.cellColor);
13305
+ draft.formulaTextColor = colorInputValue(style.textColor, DEFAULT_COLORS.comparisonText);
13306
+ draft.formulaCellColor = colorInputValue(style.cellColor, DEFAULT_COLORS.comparisonCell);
13307
+ }
13142
13308
  return draft;
13143
13309
  }
13144
13310
  function buildConditionalRule(draft) {
@@ -13179,6 +13345,34 @@ function buildConditionalRule(draft) {
13179
13345
  value
13180
13346
  ]
13181
13347
  };
13348
+ } else if ('formula' === draft.type) {
13349
+ const formula = draft.formula.trim();
13350
+ if (!formula) return {
13351
+ error: '请输入条件格式公式。'
13352
+ };
13353
+ const formulaSource = formula.replace(/^=/, '').trim();
13354
+ if (!formulaSource) return {
13355
+ error: '请输入条件格式公式,例如 =A2>0。'
13356
+ };
13357
+ if (Array.from(formulaSource).length > 255) return {
13358
+ error: '条件格式公式不能超过 255 个字符。'
13359
+ };
13360
+ if (!draft.formulaUseTextColor && !draft.formulaUseCellColor) return {
13361
+ error: '至少启用一种文字或填充颜色。'
13362
+ };
13363
+ rule = {
13364
+ type: 'default',
13365
+ cellrange,
13366
+ format: {
13367
+ textColor: draft.formulaUseTextColor ? draft.formulaTextColor : null,
13368
+ cellColor: draft.formulaUseCellColor ? draft.formulaCellColor : null
13369
+ },
13370
+ conditionName: 'formula',
13371
+ conditionRange: [],
13372
+ conditionValue: [
13373
+ `=${formulaSource}`
13374
+ ]
13375
+ };
13182
13376
  } else if ('dataBar' === draft.type) {
13183
13377
  const thresholds = savedVisualThresholds(draft.barThresholds);
13184
13378
  const minLength = Number(draft.barMinLength);
@@ -14429,7 +14623,7 @@ function formatSpreadsheetRowPageBreaks(breaks) {
14429
14623
  return normalizedBreaks(breaks).map((row)=>String(row + 1)).join(', ');
14430
14624
  }
14431
14625
  function parseSpreadsheetColumnPageBreaks(value, maximumColumn) {
14432
- return parsePageBreakList(value, maximumColumn, work_spreadsheet_page_breaks_decodeColumn);
14626
+ return parsePageBreakList(value, maximumColumn, decodeColumn);
14433
14627
  }
14434
14628
  function formatSpreadsheetColumnPageBreaks(breaks) {
14435
14629
  return normalizedBreaks(breaks).map(encodeColumn).join(', ');
@@ -14449,7 +14643,7 @@ function parsePageBreakList(value, maximum, decode) {
14449
14643
  function normalizedBreaks(breaks) {
14450
14644
  return Array.from(new Set((breaks ?? []).filter((value)=>Number.isSafeInteger(value) && value > 0))).sort((left, right)=>left - right);
14451
14645
  }
14452
- function work_spreadsheet_page_breaks_decodeColumn(token) {
14646
+ function decodeColumn(token) {
14453
14647
  const match = /^\$?([A-Z]+)$/i.exec(token);
14454
14648
  if (!match) return null;
14455
14649
  let value = 0;
@@ -21598,6 +21792,10 @@ function SpreadsheetConditionalFormatPanel({ content, onChange }) {
21598
21792
  value: 'icons',
21599
21793
  label: '图标集'
21600
21794
  },
21795
+ {
21796
+ value: 'formula',
21797
+ label: '自定义公式'
21798
+ },
21601
21799
  ...'toolbarRule' === draft.type ? [
21602
21800
  {
21603
21801
  value: 'toolbarRule',
@@ -21657,6 +21855,76 @@ function SpreadsheetConditionalFormatPanel({ content, onChange }) {
21657
21855
  ...draft,
21658
21856
  ...patch
21659
21857
  })
21858
+ }) : 'formula' === draft.type ? /*#__PURE__*/ jsxs(Fragment, {
21859
+ children: [
21860
+ /*#__PURE__*/ jsxs("div", {
21861
+ className: "work-office-field reference",
21862
+ children: [
21863
+ /*#__PURE__*/ jsx("span", {
21864
+ children: "公式"
21865
+ }),
21866
+ /*#__PURE__*/ jsx(office_text_field_OfficeTextField, {
21867
+ "aria-label": "条件格式公式",
21868
+ value: draft.formula,
21869
+ maxLength: 256,
21870
+ placeholder: "=A2>0",
21871
+ onChange: (event)=>setDraft({
21872
+ ...draft,
21873
+ formula: event.target.value
21874
+ })
21875
+ }),
21876
+ /*#__PURE__*/ jsxs("small", {
21877
+ children: [
21878
+ "以应用范围左上角为相对锚点;只读取本地缓存值。",
21879
+ ' ',
21880
+ Array.from(draft.formula.replace(/^=/, '')).length,
21881
+ "/",
21882
+ 255
21883
+ ]
21884
+ })
21885
+ ]
21886
+ }),
21887
+ /*#__PURE__*/ jsx("p", {
21888
+ className: "work-office-field-hint",
21889
+ children: "支持常用 Excel 函数、相对/绝对引用和有限区域;无法本地求值时保持原样。"
21890
+ }),
21891
+ /*#__PURE__*/ jsx(OfficeCheckbox, {
21892
+ className: "toggle",
21893
+ ariaLabel: "公式规则使用文字颜色",
21894
+ checked: draft.formulaUseTextColor,
21895
+ onCheckedChange: (formulaUseTextColor)=>setDraft({
21896
+ ...draft,
21897
+ formulaUseTextColor
21898
+ }),
21899
+ children: "使用文字颜色"
21900
+ }),
21901
+ draft.formulaUseTextColor && /*#__PURE__*/ jsx(ColorField, {
21902
+ label: "文字颜色",
21903
+ value: draft.formulaTextColor,
21904
+ onChange: (formulaTextColor)=>setDraft({
21905
+ ...draft,
21906
+ formulaTextColor
21907
+ })
21908
+ }),
21909
+ /*#__PURE__*/ jsx(OfficeCheckbox, {
21910
+ className: "toggle",
21911
+ ariaLabel: "公式规则使用填充颜色",
21912
+ checked: draft.formulaUseCellColor,
21913
+ onCheckedChange: (formulaUseCellColor)=>setDraft({
21914
+ ...draft,
21915
+ formulaUseCellColor
21916
+ }),
21917
+ children: "使用填充颜色"
21918
+ }),
21919
+ draft.formulaUseCellColor && /*#__PURE__*/ jsx(ColorField, {
21920
+ label: "填充颜色",
21921
+ value: draft.formulaCellColor,
21922
+ onChange: (formulaCellColor)=>setDraft({
21923
+ ...draft,
21924
+ formulaCellColor
21925
+ })
21926
+ })
21927
+ ]
21660
21928
  }) : 'colorGradation' === draft.type ? /*#__PURE__*/ jsxs(Fragment, {
21661
21929
  children: [
21662
21930
  /*#__PURE__*/ jsxs("div", {
@@ -27185,7 +27453,6 @@ function cloneSpreadsheetDataValidationItem(item) {
27185
27453
  };
27186
27454
  }
27187
27455
  const MAX_SPREADSHEET_CUSTOM_VALIDATION_FORMULA_LENGTH = 255;
27188
- const MAX_SPREADSHEET_CUSTOM_VALIDATION_REFERENCED_CELLS = 1024;
27189
27456
  function evaluateSpreadsheetCustomValidation(content, sheetId, row, column, item, value) {
27190
27457
  const sheet = content.sheets.find((candidate)=>candidate.id === sheetId);
27191
27458
  if (!sheet) return unsupportedCustomValidation('找不到自定义公式所在的工作表。');
@@ -27200,122 +27467,30 @@ function evaluateSpreadsheetCustomValidation(content, sheetId, row, column, item
27200
27467
  if (Array.from(formula).length > MAX_SPREADSHEET_CUSTOM_VALIDATION_FORMULA_LENGTH) return unsupportedCustomValidation('自定义公式超过 255 个字符。');
27201
27468
  const ranges = parseSpreadsheetCellRanges(item.rangeTxt);
27202
27469
  const anchor = spreadsheetCustomValidationAnchor(ranges, row, column);
27203
- const evaluator = new SpreadsheetCustomFormulaEvaluator(content, sheet, row, column, anchor, normalizedProposed);
27204
- return evaluator.evaluate(formula);
27205
- }
27206
- class SpreadsheetCustomFormulaEvaluator {
27207
- content;
27208
- currentSheet;
27209
- targetRow;
27210
- targetColumn;
27211
- anchor;
27212
- proposedValue;
27213
- sheetsByName = new Map();
27214
- referencedCells = 0;
27215
- unsupportedMessage;
27216
- constructor(content, currentSheet, targetRow, targetColumn, anchor, proposedValue){
27217
- this.content = content;
27218
- this.currentSheet = currentSheet;
27219
- this.targetRow = targetRow;
27220
- this.targetColumn = targetColumn;
27221
- this.anchor = anchor;
27222
- this.proposedValue = proposedValue;
27223
- for (const sheet of content.sheets)this.sheetsByName.set(sheet.name.trim().toLocaleLowerCase(), sheet);
27224
- }
27225
- evaluate(formula) {
27226
- const allowedFunctions = new Set(SUPPORTED_FORMULAS.map((name)=>name.toUpperCase()));
27227
- const parser = new Parser();
27228
- parser.setFunction('ROW', (parameters)=>parameters.length ? null : this.targetRow + 1).setFunction('COLUMN', (parameters)=>parameters.length ? null : this.targetColumn + 1);
27229
- let unsupportedFunction;
27230
- parser.on('callFunction', (name)=>{
27231
- const normalized = name.toUpperCase();
27232
- if (!allowedFunctions.has(normalized)) unsupportedFunction ??= normalized;
27233
- });
27234
- parser.on('callCellValue', (coordinate, _options, done)=>{
27235
- const resolved = this.resolveCoordinate(this.currentSheet, coordinate);
27236
- if (!resolved) return void done(null);
27237
- done(this.readCell(resolved));
27238
- });
27239
- parser.on('callRangeValue', (start, end, _options, done)=>{
27240
- done(this.readRange(start, end));
27241
- });
27242
- const parsed = parser.parse(normalizeFormulaForFortuneParser(formula), {
27243
- sheetId: this.currentSheet.id
27244
- });
27245
- if (unsupportedFunction) return unsupportedCustomValidation(`自定义公式函数“${unsupportedFunction}”无法在浏览器本地求值。`);
27246
- if (this.unsupportedMessage) return unsupportedCustomValidation(this.unsupportedMessage);
27247
- if (parsed.error) return unsupportedCustomValidation('自定义公式无法求值,请检查函数、引用和括号。');
27248
- return {
27249
- supported: true,
27250
- valid: spreadsheetCustomValidationBoolean(parsed.result)
27251
- };
27252
- }
27253
- resolveCoordinate(fallbackSheet, coordinate) {
27254
- const sheet = coordinate.sheetName ? this.sheetsByName.get(coordinate.sheetName.trim().toLocaleLowerCase()) : fallbackSheet;
27255
- if (!sheet) {
27256
- this.unsupportedMessage = '自定义公式引用了不存在的工作表。';
27257
- return null;
27258
- }
27259
- if (coordinate.row.index < 0 || coordinate.column.index < 0) {
27260
- this.unsupportedMessage = '自定义公式暂不支持整行或整列引用,请改用有限区域。';
27261
- return null;
27262
- }
27263
- const resolvedRow = coordinate.row.isAbsolute ? coordinate.row.index : this.targetRow + (coordinate.row.index - this.anchor.row);
27264
- const resolvedColumn = coordinate.column.isAbsolute ? coordinate.column.index : this.targetColumn + (coordinate.column.index - this.anchor.column);
27265
- if (resolvedRow < 0 || resolvedColumn < 0 || resolvedRow >= 1048576 || resolvedColumn >= 16384) {
27266
- this.unsupportedMessage = '自定义公式引用超出了工作表边界。';
27267
- return null;
27268
- }
27269
- return {
27270
- sheet,
27271
- row: resolvedRow,
27272
- column: resolvedColumn
27273
- };
27274
- }
27275
- readRange(start, end) {
27276
- const startCoordinate = this.resolveCoordinate(this.currentSheet, start);
27277
- const rangeSheet = start.sheetName ? this.sheetsByName.get(start.sheetName.trim().toLocaleLowerCase()) : this.currentSheet;
27278
- const endCoordinate = rangeSheet ? this.resolveCoordinate(rangeSheet, end) : null;
27279
- if (!startCoordinate || !endCoordinate || startCoordinate.sheet.id !== endCoordinate.sheet.id) {
27280
- this.unsupportedMessage ??= '自定义公式区域引用必须位于同一工作表。';
27281
- return [];
27282
- }
27283
- const rowStart = Math.min(startCoordinate.row, endCoordinate.row);
27284
- const rowEnd = Math.max(startCoordinate.row, endCoordinate.row);
27285
- const columnStart = Math.min(startCoordinate.column, endCoordinate.column);
27286
- const columnEnd = Math.max(startCoordinate.column, endCoordinate.column);
27287
- const area = (rowEnd - rowStart + 1) * (columnEnd - columnStart + 1);
27288
- if (area <= 0 || area > MAX_SPREADSHEET_CUSTOM_VALIDATION_REFERENCED_CELLS - this.referencedCells) {
27289
- this.unsupportedMessage = '自定义公式一次最多读取 1,024 个单元格。';
27290
- return [];
27291
- }
27292
- const values = [];
27293
- for(let currentRow = rowStart; currentRow <= rowEnd; currentRow += 1){
27294
- const row = [];
27295
- for(let currentColumn = columnStart; currentColumn <= columnEnd; currentColumn += 1)row.push(this.readCell({
27296
- sheet: startCoordinate.sheet,
27297
- row: currentRow,
27298
- column: currentColumn
27299
- }));
27300
- values.push(row);
27301
- }
27302
- return values;
27303
- }
27304
- readCell(coordinate) {
27305
- this.referencedCells += 1;
27306
- if (this.referencedCells > MAX_SPREADSHEET_CUSTOM_VALIDATION_REFERENCED_CELLS) {
27307
- this.unsupportedMessage = '自定义公式一次最多读取 1,024 个单元格。';
27308
- return null;
27309
- }
27310
- if (coordinate.sheet.id === this.currentSheet.id && coordinate.row === this.targetRow && coordinate.column === this.targetColumn) return this.proposedValue;
27311
- const cell = spreadsheetCustomValidationCellAt(coordinate.sheet, coordinate.row, coordinate.column);
27312
- if (!cell) return null;
27313
- if (cell.f && void 0 === cell.v) {
27314
- this.unsupportedMessage ??= '自定义公式引用了尚未计算的公式单元格。';
27315
- return null;
27316
- }
27317
- return cell.v ?? null;
27318
- }
27470
+ const result = evaluateSpreadsheetLocalFormula(formula, {
27471
+ sheets: content.sheets.flatMap((candidate)=>candidate.id ? [
27472
+ {
27473
+ ...candidate,
27474
+ id: candidate.id
27475
+ }
27476
+ ] : []),
27477
+ sheetId,
27478
+ row,
27479
+ column,
27480
+ anchorRow: anchor?.row,
27481
+ anchorColumn: anchor?.column,
27482
+ proposedValue: normalizedProposed,
27483
+ useProposedValue: true
27484
+ });
27485
+ if (!result.supported) return {
27486
+ supported: false,
27487
+ valid: false,
27488
+ message: result.message ? `自定义${result.message}` : result.message
27489
+ };
27490
+ return {
27491
+ supported: true,
27492
+ valid: spreadsheetLocalFormulaBoolean(result.value)
27493
+ };
27319
27494
  }
27320
27495
  function spreadsheetCustomValidationAnchor(ranges, row, column) {
27321
27496
  const range = ranges?.find((candidate)=>spreadsheetCellRangeContains(candidate, row, column));
@@ -27324,10 +27499,6 @@ function spreadsheetCustomValidationAnchor(ranges, row, column) {
27324
27499
  column: range?.column[0] ?? column
27325
27500
  };
27326
27501
  }
27327
- function spreadsheetCustomValidationCellAt(sheet, row, column) {
27328
- if (void 0 !== sheet.data) return sheet.data[row]?.[column] ?? null;
27329
- return sheet.celldata?.find((entry)=>entry.r === row && entry.c === column)?.v ?? null;
27330
- }
27331
27502
  function spreadsheetCustomValidationValue(value) {
27332
27503
  if (!value || 'object' != typeof value || Array.isArray(value)) return value;
27333
27504
  if ('v' in value) return value.v;
@@ -27336,11 +27507,6 @@ function spreadsheetCustomValidationValue(value) {
27336
27507
  function spreadsheetCustomValidationBlank(value) {
27337
27508
  return null == value || 'string' == typeof value && '' === value.trim();
27338
27509
  }
27339
- function spreadsheetCustomValidationBoolean(value) {
27340
- if ('boolean' == typeof value) return value;
27341
- if ('number' == typeof value) return Number.isFinite(value) && 0 !== value;
27342
- return 'string' == typeof value && 'TRUE' === value.trim().toUpperCase();
27343
- }
27344
27510
  function unsupportedCustomValidation(message) {
27345
27511
  return {
27346
27512
  supported: false,
@@ -30267,7 +30433,7 @@ function SpreadsheetEditorSurface({ autoFocus = true, content, collaborationHist
30267
30433
  const conditionalStylesBySheet = useMemo(()=>new Map(materializedContent.sheets.flatMap((sheet)=>sheet.id ? [
30268
30434
  [
30269
30435
  sheet.id,
30270
- spreadsheetConditionalFormatStyles(sheet)
30436
+ spreadsheetConditionalFormatStyles(sheet, materializedContent.sheets)
30271
30437
  ]
30272
30438
  ] : [])), [
30273
30439
  materializedContent.sheets
package/dist/4121.js CHANGED
@@ -2905,6 +2905,13 @@ const WORK_TEMPLATES = [
2905
2905
  description: '自定义公式、下拉列表、输入提示与错误警告',
2906
2906
  accent: '#13795b'
2907
2907
  },
2908
+ {
2909
+ id: 'conditional-format',
2910
+ kind: 'spreadsheet',
2911
+ name: '公式条件格式',
2912
+ description: '相对引用、跨表阈值、停止后续规则与本地实时强调',
2913
+ accent: '#2f6fed'
2914
+ },
2908
2915
  {
2909
2916
  id: 'structured-references',
2910
2917
  kind: 'spreadsheet',
@@ -2965,6 +2972,7 @@ function initialTitle(templateId, kind) {
2965
2972
  'proofing-languages': '校对语言示例',
2966
2973
  'quarterly-plan': '季度执行计划',
2967
2974
  'data-validation': '数据验证示例',
2975
+ 'conditional-format': '公式条件格式示例',
2968
2976
  'structured-references': '结构化引用示例',
2969
2977
  'strategy-deck': '业务策略汇报',
2970
2978
  'animated-deck': '进入与退出动画示例'
@@ -3265,6 +3273,10 @@ function contentForTemplate(templateId) {
3265
3273
  type: 'spreadsheet',
3266
3274
  sheets: dataValidationTemplateSheets()
3267
3275
  };
3276
+ if ('conditional-format' === templateId) return {
3277
+ type: 'spreadsheet',
3278
+ sheets: conditionalFormatTemplateSheets()
3279
+ };
3268
3280
  if ('structured-references' === templateId) return {
3269
3281
  type: 'spreadsheet',
3270
3282
  sheets: structuredReferenceTemplateSheets()
@@ -3647,6 +3659,247 @@ function dataValidationTemplateSheets() {
3647
3659
  }
3648
3660
  ];
3649
3661
  }
3662
+ function conditionalFormatTemplateSheets() {
3663
+ const status = emptyMatrix(18, 8);
3664
+ status[0][0] = styledCell('公式条件格式 · 发布看板', {
3665
+ bl: 1,
3666
+ fs: 16,
3667
+ fc: '#ffffff',
3668
+ bg: '#2f6fed'
3669
+ });
3670
+ status[1][0] = styledCell('公式以 A2:H7 左上角为锚点;先标记阻塞项,再强调达到阈值的任务。', {
3671
+ fc: '#49617f',
3672
+ fs: 10
3673
+ });
3674
+ [
3675
+ '任务',
3676
+ '负责人',
3677
+ '完成率',
3678
+ '状态',
3679
+ '预算',
3680
+ '实际',
3681
+ '偏差',
3682
+ '备注'
3683
+ ].forEach((value, column)=>{
3684
+ status[2][column] = headerCell(value);
3685
+ });
3686
+ const rows = [
3687
+ [
3688
+ '需求确认',
3689
+ 'Avery',
3690
+ 1,
3691
+ '完成',
3692
+ 12000,
3693
+ 11800,
3694
+ '按期',
3695
+ '可发布'
3696
+ ],
3697
+ [
3698
+ '接口联调',
3699
+ 'Morgan',
3700
+ 0.82,
3701
+ '进行中',
3702
+ 18000,
3703
+ 20100,
3704
+ '超支',
3705
+ '关注预算'
3706
+ ],
3707
+ [
3708
+ '回归测试',
3709
+ 'Riley',
3710
+ 0.54,
3711
+ '阻塞',
3712
+ 15000,
3713
+ 7600,
3714
+ '暂停',
3715
+ '等待依赖'
3716
+ ],
3717
+ [
3718
+ '文档发布',
3719
+ 'Jordan',
3720
+ 0.76,
3721
+ '进行中',
3722
+ 9000,
3723
+ 8800,
3724
+ '按期',
3725
+ '待审阅'
3726
+ ],
3727
+ [
3728
+ '版本发布',
3729
+ 'Taylor',
3730
+ 0.38,
3731
+ '未开始',
3732
+ 22000,
3733
+ 0,
3734
+ '未开始',
3735
+ '尚未排期'
3736
+ ],
3737
+ [
3738
+ '复盘会议',
3739
+ 'Avery',
3740
+ 0.9,
3741
+ '进行中',
3742
+ 4000,
3743
+ 3500,
3744
+ '按期',
3745
+ '本周完成'
3746
+ ]
3747
+ ];
3748
+ rows.forEach((row, rowIndex)=>{
3749
+ row.forEach((value, columnIndex)=>{
3750
+ status[rowIndex + 3][columnIndex] = styledCell(value, {
3751
+ bg: rowIndex % 2 ? '#f6f9ff' : '#ffffff',
3752
+ ...2 === columnIndex ? {
3753
+ ct: {
3754
+ fa: '0%',
3755
+ t: 'n'
3756
+ },
3757
+ m: `${Math.round(100 * Number(value))}%`
3758
+ } : {},
3759
+ ...4 === columnIndex || 5 === columnIndex ? {
3760
+ ct: {
3761
+ fa: '#,##0',
3762
+ t: 'n'
3763
+ },
3764
+ m: Number(value).toLocaleString()
3765
+ } : {}
3766
+ });
3767
+ });
3768
+ });
3769
+ status[11][0] = styledCell('本地规则示例', {
3770
+ bl: 1,
3771
+ fc: '#244b9b'
3772
+ });
3773
+ status[12][0] = styledCell('阻塞项(停止后续规则)');
3774
+ status[12][1] = styledCell('=$D4="阻塞"');
3775
+ status[13][0] = styledCell('达标项(跨表阈值)');
3776
+ status[13][1] = styledCell('=AND($C4>=Limits!$A$2,$D4<>"阻塞")');
3777
+ const blockRule = {
3778
+ type: 'default',
3779
+ cellrange: [
3780
+ {
3781
+ row: [
3782
+ 3,
3783
+ 8
3784
+ ],
3785
+ column: [
3786
+ 0,
3787
+ 7
3788
+ ]
3789
+ }
3790
+ ],
3791
+ format: {
3792
+ textColor: '#9c0006',
3793
+ cellColor: '#ffc7ce'
3794
+ },
3795
+ conditionName: 'formula',
3796
+ conditionRange: [],
3797
+ conditionValue: [
3798
+ '=$D4="阻塞"'
3799
+ ],
3800
+ stopIfTrue: true
3801
+ };
3802
+ const completedRule = {
3803
+ type: 'default',
3804
+ cellrange: [
3805
+ {
3806
+ row: [
3807
+ 3,
3808
+ 8
3809
+ ],
3810
+ column: [
3811
+ 0,
3812
+ 7
3813
+ ]
3814
+ }
3815
+ ],
3816
+ format: {
3817
+ textColor: '#006100',
3818
+ cellColor: '#c6efce'
3819
+ },
3820
+ conditionName: 'formula',
3821
+ conditionRange: [],
3822
+ conditionValue: [
3823
+ '=AND($C4>=Limits!$A$2,$D4<>"阻塞")'
3824
+ ]
3825
+ };
3826
+ return [
3827
+ {
3828
+ id: createWorkId('sheet'),
3829
+ name: 'Status',
3830
+ status: 1,
3831
+ order: 0,
3832
+ row: 18,
3833
+ column: 8,
3834
+ data: status,
3835
+ luckysheet_conditionformat_save: [
3836
+ blockRule,
3837
+ completedRule
3838
+ ],
3839
+ config: {
3840
+ columnlen: {
3841
+ 0: 132,
3842
+ 1: 92,
3843
+ 2: 84,
3844
+ 3: 86,
3845
+ 4: 92,
3846
+ 5: 92,
3847
+ 6: 84,
3848
+ 7: 116
3849
+ },
3850
+ rowlen: {
3851
+ 0: 34,
3852
+ 1: 26,
3853
+ 2: 28,
3854
+ 12: 24,
3855
+ 13: 24
3856
+ },
3857
+ merge: {
3858
+ '0_0': {
3859
+ r: 0,
3860
+ c: 0,
3861
+ rs: 1,
3862
+ cs: 8
3863
+ }
3864
+ }
3865
+ }
3866
+ },
3867
+ {
3868
+ id: createWorkId('sheet'),
3869
+ name: 'Limits',
3870
+ status: 0,
3871
+ order: 1,
3872
+ row: 8,
3873
+ column: 4,
3874
+ data: [
3875
+ [
3876
+ headerCell('完成率阈值'),
3877
+ styledCell('说明')
3878
+ ],
3879
+ [
3880
+ styledCell(0.8, {
3881
+ ct: {
3882
+ fa: '0%',
3883
+ t: 'n'
3884
+ },
3885
+ m: '80%'
3886
+ }),
3887
+ styledCell('达到阈值并且未阻塞的任务使用绿色强调。')
3888
+ ]
3889
+ ],
3890
+ config: {
3891
+ columnlen: {
3892
+ 0: 120,
3893
+ 1: 300
3894
+ },
3895
+ rowlen: {
3896
+ 0: 28,
3897
+ 1: 36
3898
+ }
3899
+ }
3900
+ }
3901
+ ];
3902
+ }
3650
3903
  function structuredReferenceTemplateSheets() {
3651
3904
  const sales = emptyMatrix(16, 10);
3652
3905
  sales[0][0] = styledCell('Sales · 结构化引用', {
@@ -15,7 +15,7 @@ export declare const SPREADSHEET_CONDITIONAL_COMPARISONS: {
15
15
  }[];
16
16
  export interface ConditionalRuleDraft {
17
17
  sheetId: string;
18
- type: 'cellComparison' | 'colorGradation' | 'dataBar' | 'icons' | 'toolbarRule';
18
+ type: 'cellComparison' | 'colorGradation' | 'dataBar' | 'icons' | 'formula' | 'toolbarRule';
19
19
  reference: string;
20
20
  stopIfTrue: boolean;
21
21
  preservedRule: FortuneConditionalFormatRule | null;
@@ -26,6 +26,11 @@ export interface ConditionalRuleDraft {
26
26
  comparisonCellColor: string;
27
27
  comparisonUseTextColor: boolean;
28
28
  comparisonUseCellColor: boolean;
29
+ formula: string;
30
+ formulaTextColor: string;
31
+ formulaCellColor: string;
32
+ formulaUseTextColor: boolean;
33
+ formulaUseCellColor: boolean;
29
34
  scaleSize: '2' | '3';
30
35
  minimumColor: string;
31
36
  midpointColor: string;
@@ -1,10 +1,12 @@
1
1
  import type { Sheet } from '@fortune-sheet/core';
2
2
  import { type SpreadsheetConditionalDataBar } from './work-spreadsheet-conditional-data-bar';
3
3
  import { type SpreadsheetConditionalIcon } from './work-spreadsheet-conditional-icons';
4
+ /** Full formula-rule scans stay bounded; larger ranges use materialized cells. */
5
+ export declare const MAX_SPREADSHEET_CONDITIONAL_FORMULA_SCAN_CELLS = 10000;
4
6
  export interface SpreadsheetConditionalCellStyle {
5
7
  textColor?: string;
6
8
  cellColor?: string;
7
9
  dataBar?: SpreadsheetConditionalDataBar;
8
10
  icon?: SpreadsheetConditionalIcon;
9
11
  }
10
- export declare function spreadsheetConditionalFormatStyles(sheet: Sheet): Map<string, SpreadsheetConditionalCellStyle>;
12
+ export declare function spreadsheetConditionalFormatStyles(sheet: Sheet, formulaSheets?: readonly Sheet[]): Map<string, SpreadsheetConditionalCellStyle>;
@@ -0,0 +1,43 @@
1
+ import type { CellMatrix } from '@fortune-sheet/core';
2
+ /** OOXML and the browser editor keep local rule formulas deliberately small. */
3
+ export declare const MAX_SPREADSHEET_LOCAL_FORMULA_LENGTH = 255;
4
+ /** Keep synchronous browser formula evaluation bounded for hostile workbooks. */
5
+ export declare const MAX_SPREADSHEET_LOCAL_FORMULA_REFERENCED_CELLS = 1024;
6
+ export interface SpreadsheetFormulaSheet {
7
+ id: string;
8
+ name: string;
9
+ data?: CellMatrix;
10
+ celldata?: readonly SpreadsheetFormulaCell[];
11
+ }
12
+ export interface SpreadsheetFormulaCell {
13
+ r: number;
14
+ c: number;
15
+ v: unknown;
16
+ }
17
+ export interface SpreadsheetLocalFormulaOptions {
18
+ sheets: readonly SpreadsheetFormulaSheet[];
19
+ sheetId: string;
20
+ row: number;
21
+ column: number;
22
+ /** The first row of the rule/validation range. Defaults to the target row. */
23
+ anchorRow?: number;
24
+ /** The first column of the rule/validation range. Defaults to the target column. */
25
+ anchorColumn?: number;
26
+ /** Substitute the target cell with this value, as data validation does. */
27
+ proposedValue?: unknown;
28
+ useProposedValue?: boolean;
29
+ }
30
+ export interface SpreadsheetLocalFormulaResult {
31
+ supported: boolean;
32
+ value?: unknown;
33
+ message?: string;
34
+ }
35
+ /**
36
+ * Evaluate a bounded Excel formula against the workbook's cached local data.
37
+ *
38
+ * This is intentionally shared by custom data validation and conditional
39
+ * formatting. It never recalculates formula cells or reaches a remote source;
40
+ * an unavailable dependency therefore fails closed.
41
+ */
42
+ export declare function evaluateSpreadsheetLocalFormula(formula: string, options: SpreadsheetLocalFormulaOptions): SpreadsheetLocalFormulaResult;
43
+ export declare function spreadsheetLocalFormulaBoolean(value: unknown): boolean;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a3s-lab/office",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Open-source collaborative browser editors for documents, Markdown, spreadsheets, presentations, and PDFs.",
5
5
  "keywords": [
6
6
  "office",
@@ -106,6 +106,7 @@
106
106
  "playground:visual:document-opentype": "playwright test visual-tests/document-opentype.functional.spec.ts --config playwright.config.ts",
107
107
  "playground:visual:document-strike": "playwright test visual-tests/document-strike.functional.spec.ts --config playwright.config.ts",
108
108
  "playground:visual:spreadsheet-data-validation": "playwright test visual-tests/spreadsheet-data-validation.functional.spec.ts --config playwright.config.ts",
109
+ "playground:visual:spreadsheet-conditional-format": "playwright test visual-tests/spreadsheet-conditional-format.functional.spec.ts --config playwright.config.ts",
109
110
  "playground:visual:spreadsheet-date-time": "playwright test visual-tests/spreadsheet-date-time.functional.spec.ts --config playwright.config.ts",
110
111
  "playground:visual:spreadsheet-copy-from-above": "playwright test visual-tests/spreadsheet-copy-from-above.functional.spec.ts --config playwright.config.ts",
111
112
  "playground:visual:spreadsheet-custom-sort": "playwright test visual-tests/spreadsheet-custom-sort.functional.spec.ts --config playwright.config.ts",
@@ -224,6 +225,8 @@
224
225
  "test:e2e:spreadsheet-go-to:check": "a3s-test check tests/e2e/spreadsheet-go-to.acl --json",
225
226
  "test:e2e:spreadsheet-data-validation": "A3S_TEST_SUITE=tests/e2e/spreadsheet-data-validation.acl bash scripts/run-a3s-test-web-gate.sh",
226
227
  "test:e2e:spreadsheet-data-validation:check": "a3s-test check tests/e2e/spreadsheet-data-validation.acl --json",
228
+ "test:e2e:spreadsheet-conditional-format": "A3S_TEST_SUITE=tests/e2e/spreadsheet-conditional-format.acl bash scripts/run-a3s-test-web-gate.sh",
229
+ "test:e2e:spreadsheet-conditional-format:check": "a3s-test check tests/e2e/spreadsheet-conditional-format.acl --json",
227
230
  "test:e2e:spreadsheet-diagonal-borders": "a3s-test run tests/e2e/spreadsheet-diagonal-borders.acl --json",
228
231
  "test:e2e:spreadsheet-diagonal-borders:check": "a3s-test check tests/e2e/spreadsheet-diagonal-borders.acl --json",
229
232
  "test:e2e:spreadsheet-date-time": "a3s-test run tests/e2e/spreadsheet-date-time.acl --json",