@a3s-lab/office 0.41.0 → 0.42.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.42.0` extends Spreadsheet data validation with a local custom-formula
313
+ workflow that remains bounded, inspectable, and native-file compatible:
314
+
315
+ - Author a formula with an optional `=` prefix in the same accessible dialog;
316
+ relative references are anchored per selected range and the proposed value
317
+ is evaluated before it enters the controlled workbook.
318
+ - Common local functions, cell/range references, and sheet-qualified references
319
+ are evaluated synchronously with a 255-character formula and 1,024-cell read
320
+ budget. External, whole-row/column, missing-sheet, and uncached-formula
321
+ references fail closed instead of silently accepting an unsafe edit.
322
+ - Custom rules round-trip through XLSX and are demonstrated in the public
323
+ **新建 → 数据验证** template, alongside the existing list, date, and alert
324
+ branches. Paste and object-level batch writes remain separate preflight
325
+ boundaries.
326
+
312
327
  Version `0.41.0` aligns Spreadsheet data-validation alerts with the local,
313
328
  testable interaction model used by Traditional Office:
314
329
 
package/dist/0~7048.js CHANGED
@@ -2733,4 +2733,4 @@ function abortError() {
2733
2733
  function disposedError() {
2734
2734
  return new Error('Office kernel client is disposed.');
2735
2735
  }
2736
- export { CollectionState, CommittedOfficeNumberField, OfficeCheckbox, OfficeNumberField, createOfficeKernelClient, handleOfficeTaskPaneKeyDown, normalizeOfficeFontFamily, officeFontFamilies, officeFontFamilyLabel, useOfficeDialog, useOfficeTaskPaneEscape, useOfficeTaskPaneModal };
2736
+ export { CollectionState, CommittedOfficeNumberField, OfficeCheckbox, OfficeNumberField, createOfficeKernelClient, handleOfficeTaskPaneKeyDown, normalizeFormulaForFortuneParser, normalizeOfficeFontFamily, officeFontFamilies, officeFontFamilyLabel, useOfficeDialog, useOfficeTaskPaneEscape, useOfficeTaskPaneModal };
@@ -4,12 +4,13 @@ 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
6
  import { convertSpanToShareString, genarate, indexToColumnChar, update as core_update } from "@fortune-sheet/core";
7
+ import { Parser, SUPPORTED_FORMULAS } from "@fortune-sheet/formula-parser";
7
8
  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";
8
9
  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";
9
10
  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";
10
11
  import { moveOfficeMenuFocus, stepOfficeZoom, useOfficeRemoteParticipants, Popover, Dialog, officePresenceColor, useOfficePublishPresenceLocation, button_Button, icon_button_IconButton, moveOfficeGridMenuFocus, office_text_field_OfficeTextField, useDialogFocusScope, useOfficeEditorWheelZoom } from "./0~8136.js";
11
12
  import { isOfficeKernelSpreadsheetError, normalizeCssColor, OFFICE_KERNEL_SPREADSHEET_MAX_ROWS, createOfficeId as createWorkId } from "./4121.js";
12
- import { useOfficeTaskPaneEscape, OfficeCheckbox, CommittedOfficeNumberField, useOfficeDialog, useOfficeTaskPaneModal, createOfficeKernelClient, officeFontFamilies, CollectionState, OfficeNumberField, handleOfficeTaskPaneKeyDown } from "./0~7048.js";
13
+ import { useOfficeTaskPaneEscape, OfficeCheckbox, CommittedOfficeNumberField, useOfficeDialog, useOfficeTaskPaneModal, createOfficeKernelClient, officeFontFamilies, CollectionState, OfficeNumberField, handleOfficeTaskPaneKeyDown, normalizeFormulaForFortuneParser } from "./0~7048.js";
13
14
  import { isOfficeShortcutBlocked, OfficeColorPicker } from "./0~3635.js";
14
15
  import { createOfficeEditorExtension, useOfficeEditorRuntime, useOfficeEditorKeyboardShortcuts } from "./0~3557.js";
15
16
  import { showToast } from "./6489.js";
@@ -8036,7 +8037,7 @@ const dateOperators = [
8036
8037
  'noLaterThan'
8037
8038
  ];
8038
8039
  function spreadsheetDataValidationOperators(type) {
8039
- if ('dropdown' === type) return [];
8040
+ if ('custom' === type || 'dropdown' === type) return [];
8040
8041
  return 'date' === type ? dateOperators : numericOperators;
8041
8042
  }
8042
8043
  function createSpreadsheetDataValidationDialogSource(content, target) {
@@ -8121,8 +8122,8 @@ function spreadsheetDataValidationTargetError(sheet, ranges) {
8121
8122
  }
8122
8123
  function normalizeSpreadsheetDataValidationItem(content, sheet, ranges, value) {
8123
8124
  const operators = spreadsheetDataValidationOperators(value.type);
8124
- const type2 = 'dropdown' === value.type ? '' : value.type2;
8125
- if ('dropdown' !== value.type && !operators.includes(type2)) return spreadsheetDataValidationError('invalid-operator');
8125
+ const type2 = 'dropdown' === value.type || 'custom' === value.type ? '' : value.type2;
8126
+ if ('dropdown' !== value.type && 'custom' !== value.type && !operators.includes(type2)) return spreadsheetDataValidationError('invalid-operator');
8126
8127
  const normalizedValues = normalizeSpreadsheetDataValidationValues(content, sheet, value.type, type2, value.value1, value.value2);
8127
8128
  if (!normalizedValues.ok) return normalizedValues;
8128
8129
  return {
@@ -8153,6 +8154,15 @@ function normalizeSpreadsheetDataValidationValues(content, sheet, type, type2, r
8153
8154
  const value2 = rawValue2.trim();
8154
8155
  if (!value1) return spreadsheetDataValidationError('missing-value');
8155
8156
  if ('dropdown' === type) return normalizeSpreadsheetListValidation(content, sheet, value1);
8157
+ if ('custom' === type) {
8158
+ const formula = value1.replace(/^=/, '').trim();
8159
+ if (!formula || Array.from(formula).length > 255 || /[\u0000-\u001f\u007f]/u.test(formula)) return spreadsheetDataValidationError('invalid-custom-formula');
8160
+ return {
8161
+ ok: true,
8162
+ value1: formula,
8163
+ value2: ''
8164
+ };
8165
+ }
8156
8166
  const needsSecond = 'between' === type2 || 'notBetween' === type2;
8157
8167
  if (needsSecond && !value2) return spreadsheetDataValidationError('missing-value');
8158
8168
  if ('date' === type) {
@@ -8256,6 +8266,7 @@ function spreadsheetDataValidationDialogValue(value) {
8256
8266
  }
8257
8267
  function spreadsheetDataValidationType(value) {
8258
8268
  return [
8269
+ 'custom',
8259
8270
  'date',
8260
8271
  'dropdown',
8261
8272
  'number',
@@ -8443,6 +8454,7 @@ function cloneSpreadsheetCellRange(range) {
8443
8454
  }
8444
8455
  function spreadsheetDataValidationError(code) {
8445
8456
  const messages = {
8457
+ 'invalid-custom-formula': '自定义公式必须是 255 个字符以内的本地公式,且不能包含控制字符。',
8446
8458
  'invalid-date': '请输入有效的日期、Excel 日期序号或 DATE(...) 表达式。',
8447
8459
  'invalid-list-source': '请输入不超过 255 个字符的逗号分隔列表,或有效的单行/单列区域。',
8448
8460
  'invalid-number': '请输入有效的数字;整数验证不能使用小数边界。',
@@ -26523,6 +26535,10 @@ function pruneSpreadsheetCollaborationViewState(view, content) {
26523
26535
  for (const sheetId of view.zoomBySheet.keys())if (!sheetIds.has(sheetId)) view.zoomBySheet.delete(sheetId);
26524
26536
  }
26525
26537
  const validationTypes = [
26538
+ {
26539
+ value: 'custom',
26540
+ label: '自定义公式'
26541
+ },
26526
26542
  {
26527
26543
  value: 'dropdown',
26528
26544
  label: '序列'
@@ -26679,7 +26695,7 @@ function SpreadsheetDataValidationDialog({ source, restoreFocusTarget, onApply,
26679
26695
  }, option.value))
26680
26696
  })
26681
26697
  }),
26682
- 'dropdown' !== value.type && /*#__PURE__*/ jsx(field_Field, {
26698
+ 'dropdown' !== value.type && 'custom' !== value.type && /*#__PURE__*/ jsx(field_Field, {
26683
26699
  label: "数据",
26684
26700
  children: /*#__PURE__*/ jsx("select", {
26685
26701
  value: value.type2,
@@ -26741,6 +26757,13 @@ function SpreadsheetDataValidationDialog({ source, restoreFocusTarget, onApply,
26741
26757
  setTouched(true);
26742
26758
  }
26743
26759
  })
26760
+ }) : 'custom' === value.type ? /*#__PURE__*/ jsx(SpreadsheetDataValidationCustomFormulaField, {
26761
+ value: value.value1,
26762
+ error: visibleError,
26763
+ onTouched: ()=>setTouched(true),
26764
+ onValueChange: (value1)=>update({
26765
+ value1
26766
+ })
26744
26767
  }) : /*#__PURE__*/ jsx(SpreadsheetDataValidationBoundaryFields, {
26745
26768
  type: value.type,
26746
26769
  type2: value.type2,
@@ -26900,6 +26923,45 @@ function SpreadsheetDataValidationDialog({ source, restoreFocusTarget, onApply,
26900
26923
  })
26901
26924
  });
26902
26925
  }
26926
+ function SpreadsheetDataValidationCustomFormulaField({ error, onTouched, onValueChange, value }) {
26927
+ return /*#__PURE__*/ jsxs("div", {
26928
+ className: "work-spreadsheet-data-validation-custom-formula",
26929
+ children: [
26930
+ /*#__PURE__*/ jsx(field_Field, {
26931
+ label: "公式",
26932
+ required: true,
26933
+ description: "公式必须返回 TRUE;相对引用以每个选定区域的左上角为基准。仅计算本地单元格和区域,不访问网络。",
26934
+ error: error ?? void 0,
26935
+ children: /*#__PURE__*/ jsx("textarea", {
26936
+ "aria-label": "公式",
26937
+ rows: 2,
26938
+ maxLength: 255,
26939
+ placeholder: '例如:=AND(A1<>"",A1<=100)',
26940
+ autoCapitalize: "none",
26941
+ spellCheck: false,
26942
+ value: value,
26943
+ onBlur: onTouched,
26944
+ onChange: (event)=>{
26945
+ onValueChange(event.currentTarget.value);
26946
+ onTouched();
26947
+ }
26948
+ })
26949
+ }),
26950
+ /*#__PURE__*/ jsxs("p", {
26951
+ className: "work-spreadsheet-data-validation-formula-note",
26952
+ children: [
26953
+ /*#__PURE__*/ jsx(Sigma, {
26954
+ size: 14,
26955
+ "aria-hidden": "true"
26956
+ }),
26957
+ /*#__PURE__*/ jsx("span", {
26958
+ children: "支持常用 Excel 函数、单元格引用和区域引用;无法安全求值时会阻止输入。"
26959
+ })
26960
+ ]
26961
+ })
26962
+ ]
26963
+ });
26964
+ }
26903
26965
  function SpreadsheetDataValidationBoundaryFields({ error, onTouched, onValueChange, type, type2, value1, value2 }) {
26904
26966
  const needsSecond = spreadsheetDataValidationNeedsSecondValue(type2);
26905
26967
  const inputMode = 'date' === type ? 'text' : 'decimal';
@@ -27122,6 +27184,170 @@ function cloneSpreadsheetDataValidationItem(item) {
27122
27184
  hintTitle: item.hintTitle
27123
27185
  };
27124
27186
  }
27187
+ const MAX_SPREADSHEET_CUSTOM_VALIDATION_FORMULA_LENGTH = 255;
27188
+ const MAX_SPREADSHEET_CUSTOM_VALIDATION_REFERENCED_CELLS = 1024;
27189
+ function evaluateSpreadsheetCustomValidation(content, sheetId, row, column, item, value) {
27190
+ const sheet = content.sheets.find((candidate)=>candidate.id === sheetId);
27191
+ if (!sheet) return unsupportedCustomValidation('找不到自定义公式所在的工作表。');
27192
+ const proposed = spreadsheetCustomValidationValue(value);
27193
+ const normalizedProposed = spreadsheetCustomValidationBlank(proposed) ? '' : proposed;
27194
+ if (false !== item.allowBlank && '' === normalizedProposed) return {
27195
+ supported: true,
27196
+ valid: true
27197
+ };
27198
+ const formula = item.value1.trim().replace(/^=/, '').trim();
27199
+ if (!formula) return unsupportedCustomValidation('自定义公式为空。');
27200
+ if (Array.from(formula).length > MAX_SPREADSHEET_CUSTOM_VALIDATION_FORMULA_LENGTH) return unsupportedCustomValidation('自定义公式超过 255 个字符。');
27201
+ const ranges = parseSpreadsheetCellRanges(item.rangeTxt);
27202
+ 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
+ }
27319
+ }
27320
+ function spreadsheetCustomValidationAnchor(ranges, row, column) {
27321
+ const range = ranges?.find((candidate)=>spreadsheetCellRangeContains(candidate, row, column));
27322
+ return {
27323
+ row: range?.row[0] ?? row,
27324
+ column: range?.column[0] ?? column
27325
+ };
27326
+ }
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
+ function spreadsheetCustomValidationValue(value) {
27332
+ if (!value || 'object' != typeof value || Array.isArray(value)) return value;
27333
+ if ('v' in value) return value.v;
27334
+ return value;
27335
+ }
27336
+ function spreadsheetCustomValidationBlank(value) {
27337
+ return null == value || 'string' == typeof value && '' === value.trim();
27338
+ }
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
+ function unsupportedCustomValidation(message) {
27345
+ return {
27346
+ supported: false,
27347
+ valid: false,
27348
+ message
27349
+ };
27350
+ }
27125
27351
  const spreadsheetCellFormatAttributes = [
27126
27352
  'bl',
27127
27353
  'it',
@@ -29990,6 +30216,27 @@ function SpreadsheetEditorSurface({ autoFocus = true, content, collaborationHist
29990
30216
  const requestSheetId = sheetId ?? activeSheetIdRef.current;
29991
30217
  if (!requestSheetId || !item || 'object' != typeof item || pendingDataValidationRef.current) return pendingDataValidationRef.current ? false : void 0;
29992
30218
  const validationItem = item;
30219
+ if ('custom' === validationItem.type) {
30220
+ const evaluation = evaluateSpreadsheetCustomValidation(contentRef.current, requestSheetId, row, column, validationItem, value);
30221
+ if (evaluation.supported && evaluation.valid) return true;
30222
+ const displayItem = cloneSpreadsheetDataValidationItem(validationItem);
30223
+ if (!displayItem.errorMessage?.trim() && evaluation.message) displayItem.errorMessage = evaluation.message;
30224
+ const style = spreadsheetDataValidationErrorStyle(displayItem);
30225
+ const request = {
30226
+ column,
30227
+ failureText: evaluation.message ?? failureText,
30228
+ interaction: spreadsheetDataValidationInteraction(style),
30229
+ item: displayItem,
30230
+ row,
30231
+ sheetId: requestSheetId,
30232
+ value
30233
+ };
30234
+ pendingDataValidationRef.current = request;
30235
+ queueMicrotask(()=>{
30236
+ if (pendingDataValidationRef.current === request) setPendingDataValidation(request);
30237
+ });
30238
+ return false;
30239
+ }
29993
30240
  const style = spreadsheetDataValidationErrorStyle(validationItem);
29994
30241
  const request = {
29995
30242
  column,
package/dist/4121.js CHANGED
@@ -2902,7 +2902,7 @@ const WORK_TEMPLATES = [
2902
2902
  id: 'data-validation',
2903
2903
  kind: 'spreadsheet',
2904
2904
  name: '数据验证',
2905
- description: '下拉列表、输入提示与错误警告',
2905
+ description: '自定义公式、下拉列表、输入提示与错误警告',
2906
2906
  accent: '#13795b'
2907
2907
  },
2908
2908
  {
@@ -3566,6 +3566,32 @@ function dataValidationTemplateSheets() {
3566
3566
  hintTitle: 'Priority',
3567
3567
  hintValue: '1 is highest priority; 5 is lowest.'
3568
3568
  })
3569
+ },
3570
+ {
3571
+ ranges: [
3572
+ {
3573
+ row: [
3574
+ 1,
3575
+ 5
3576
+ ],
3577
+ column: [
3578
+ 4,
3579
+ 4
3580
+ ]
3581
+ }
3582
+ ],
3583
+ item: dataValidationTemplateItem({
3584
+ type: 'custom',
3585
+ rangeTxt: 'E2:E6',
3586
+ value1: 'LEN(E2)>0',
3587
+ allowBlank: false,
3588
+ errorStyle: 'stop',
3589
+ errorTitle: 'Owner required',
3590
+ errorMessage: 'Enter an owner for every task.',
3591
+ hintShow: true,
3592
+ hintTitle: 'Owner',
3593
+ hintValue: 'The custom formula checks that this cell is not blank.'
3594
+ })
3569
3595
  }
3570
3596
  ],
3571
3597
  luckysheet_select_save: [
package/dist/core.js CHANGED
@@ -3325,6 +3325,7 @@ function parseListFormula(value) {
3325
3325
  }
3326
3326
  function fortuneValidationType(value) {
3327
3327
  const types = {
3328
+ custom: 'custom',
3328
3329
  list: 'dropdown',
3329
3330
  whole: 'number_integer',
3330
3331
  decimal: 'number',
@@ -3335,6 +3336,7 @@ function fortuneValidationType(value) {
3335
3336
  }
3336
3337
  function xlsxValidationType(value) {
3337
3338
  const types = {
3339
+ custom: 'custom',
3338
3340
  dropdown: 'list',
3339
3341
  number: 'decimal',
3340
3342
  number_integer: 'whole',
@@ -0,0 +1,18 @@
1
+ import type { WorkSpreadsheetContent, WorkSpreadsheetDataValidationItem } from '../work-types';
2
+ /** OOXML limits a data-validation formula to 255 characters. */
3
+ export declare const MAX_SPREADSHEET_CUSTOM_VALIDATION_FORMULA_LENGTH = 255;
4
+ /** Keep synchronous browser validation bounded even for a hostile workbook. */
5
+ export declare const MAX_SPREADSHEET_CUSTOM_VALIDATION_REFERENCED_CELLS = 1024;
6
+ export interface SpreadsheetCustomValidationResult {
7
+ message?: string;
8
+ supported: boolean;
9
+ valid: boolean;
10
+ }
11
+ /**
12
+ * Evaluates a local Excel custom-validation formula synchronously.
13
+ *
14
+ * The editor deliberately evaluates against cached cell values. Formula
15
+ * recalculation remains a separate kernel concern; a formula cell without a
16
+ * cached value therefore fails closed instead of silently accepting input.
17
+ */
18
+ export declare function evaluateSpreadsheetCustomValidation(content: WorkSpreadsheetContent, sheetId: string, row: number, column: number, item: WorkSpreadsheetDataValidationItem, value: unknown): SpreadsheetCustomValidationResult;
@@ -1,7 +1,7 @@
1
1
  import type { WorkSpreadsheetContent, WorkSpreadsheetDataValidationErrorStyle, WorkSpreadsheetDataValidationItem, WorkSpreadsheetSheet } from '../work-types';
2
2
  import { type SpreadsheetCellRange } from './spreadsheet-cell-range';
3
3
  export declare const MAX_SPREADSHEET_DATA_VALIDATION_CELLS = 10000;
4
- export type SpreadsheetDataValidationType = 'date' | 'dropdown' | 'number' | 'number_integer' | 'text_length';
4
+ export type SpreadsheetDataValidationType = 'custom' | 'date' | 'dropdown' | 'number' | 'number_integer' | 'text_length';
5
5
  export type SpreadsheetDataValidationOperator = 'between' | 'equal' | 'greaterOrEqualTo' | 'lessThan' | 'lessThanOrEqualTo' | 'moreThanThe' | 'noEarlierThan' | 'noLaterThan' | 'notBetween' | 'notEqualTo' | 'earlierThan' | 'laterThan';
6
6
  export interface SpreadsheetDataValidationDialogValue {
7
7
  allowBlank: boolean;
@@ -46,7 +46,7 @@ export type SpreadsheetDataValidationResult = {
46
46
  message: string;
47
47
  ok: false;
48
48
  };
49
- export type SpreadsheetDataValidationErrorCode = 'invalid-date' | 'invalid-list-source' | 'invalid-number' | 'invalid-operator' | 'invalid-range' | 'invalid-text-length' | 'missing-value' | 'multiple-list-columns' | 'out-of-bounds' | 'protected-range' | 'range-too-large' | 'sheet-not-found' | 'value-order';
49
+ export type SpreadsheetDataValidationErrorCode = 'invalid-custom-formula' | 'invalid-date' | 'invalid-list-source' | 'invalid-number' | 'invalid-operator' | 'invalid-range' | 'invalid-text-length' | 'missing-value' | 'multiple-list-columns' | 'out-of-bounds' | 'protected-range' | 'range-too-large' | 'sheet-not-found' | 'value-order';
50
50
  export declare function spreadsheetDataValidationOperators(type: SpreadsheetDataValidationType): readonly SpreadsheetDataValidationOperator[];
51
51
  export declare function createSpreadsheetDataValidationDialogSource(content: WorkSpreadsheetContent, target: SpreadsheetDataValidationTarget): SpreadsheetDataValidationDialogSource | null;
52
52
  export declare function validateSpreadsheetDataValidationRequest(content: WorkSpreadsheetContent, request: SpreadsheetDataValidationRequest): SpreadsheetDataValidationResult;
@@ -2,6 +2,7 @@ import type { WorkSpreadsheetDataValidationErrorStyle } from './work-types';
2
2
  export declare const SPREADSHEET_DATA_VALIDATION_TITLE_LIMIT = 32;
3
3
  export declare const SPREADSHEET_DATA_VALIDATION_HINT_LIMIT = 255;
4
4
  export declare const SPREADSHEET_DATA_VALIDATION_ERROR_LIMIT = 225;
5
+ export declare const SPREADSHEET_DATA_VALIDATION_FORMULA_LIMIT = 255;
5
6
  export declare const SPREADSHEET_DATA_VALIDATION_ERROR_STYLES: readonly ["stop", "warning", "information"];
6
7
  export declare function normalizeSpreadsheetDataValidationErrorStyle(value: unknown): WorkSpreadsheetDataValidationErrorStyle;
7
8
  export declare function boundedSpreadsheetDataValidationText(value: unknown, limit: number): string;
Binary file
package/dist/styles.css CHANGED
@@ -17939,6 +17939,27 @@ sub [data-office-character-position-half-points], sup [data-office-character-pos
17939
17939
  display: grid;
17940
17940
  }
17941
17941
 
17942
+ .work-spreadsheet-data-validation-custom-formula {
17943
+ gap: 8px;
17944
+ display: grid;
17945
+ }
17946
+
17947
+ .work-spreadsheet-data-validation-formula-note {
17948
+ color: var(--a3s-muted);
17949
+ align-items: flex-start;
17950
+ gap: 6px;
17951
+ margin: -2px 0 0;
17952
+ font-size: 10px;
17953
+ line-height: 1.45;
17954
+ display: flex;
17955
+ }
17956
+
17957
+ .work-spreadsheet-data-validation-formula-note svg {
17958
+ color: var(--work-spreadsheet-accent, #159469);
17959
+ flex: none;
17960
+ margin-top: 1px;
17961
+ }
17962
+
17942
17963
  .work-spreadsheet-data-validation-inline-error {
17943
17964
  color: var(--a3s-danger, #c23535);
17944
17965
  grid-column: 1 / -1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a3s-lab/office",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Open-source collaborative browser editors for documents, Markdown, spreadsheets, presentations, and PDFs.",
5
5
  "keywords": [
6
6
  "office",