diff --git a/packages/core/src/shared/numfmt.ts b/packages/core/src/shared/numfmt.ts index 36af2c48efd..0159669825a 100644 --- a/packages/core/src/shared/numfmt.ts +++ b/packages/core/src/shared/numfmt.ts @@ -15,6 +15,8 @@ */ // @ts-ignore -import numfmt from 'numfmt'; +import _numfmt from 'numfmt'; +import type { INumfmt } from './types/numfmt.type'; +const numfmt = _numfmt as INumfmt; export { numfmt }; diff --git a/packages/core/src/shared/types/numfmt.type.ts b/packages/core/src/shared/types/numfmt.type.ts new file mode 100644 index 00000000000..0109be331a9 --- /dev/null +++ b/packages/core/src/shared/types/numfmt.type.ts @@ -0,0 +1,303 @@ +/** + * Copyright 2023-present DreamNum Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type FormatType = + | 'currency' + | 'date' + | 'datetime' + | 'error' + | 'fraction' + | 'general' + | 'grouped' + | 'number' + | 'percent' + | 'scientific' + | 'text' + | 'time'; + +/** + * An arbirarty number that represents the format's specificity if you want to compare one to another. Integer comparisons roughly match Excel's resolutions when it determines which format wins out. + * + * text: 15 + * datetime: 10.8 + * date: 10.8 + * time: 10.8 + * percent: 10.6 + * currency: 10.4 + * grouped: 10.2 + * scientific: 6 + * number: 4 + * fraction: 2 + * general: 0 + * error: 0 + */ +type Level = 15 | 10.8 | 10.6 | 10.4 | 10.2 | 6 | 4 | 2 | 0; + +interface Info { + type: FormatType; + + /** + * Correspond to the output from same named functions found on the formatters. + */ + isDate: boolean; + isText: boolean; + isPercent: boolean; + + /** The maximum number of decimals this format will emit. */ + maxDecimals: number; + + /** + * 1 if the format uses color on the negative portion of the string, else a 0. This replicates Excel's CELL("color") functionality. + */ + color: 0 | 1; + + /** + * 1 if the positive portion of the number format contains an open parenthesis, else a 0. This is replicates Excel's CELL("parentheses") functionality. + */ + parentheses: 0 | 1; + + /** + * 1 if the positive portion of the format uses a thousands separator, else a 0. + */ + grouped: 1; + /** + * Corresponds to Excel's CELL("format") functionality. It is should match Excel's quirky behaviour fairly well. + */ + code: string; + /** + * The multiplier used when formatting the number (100 for percentages). + */ + scale: 1; + + /** + * An arbirarty number that represents the format's specificity if you want to compare one to another. Integer comparisons roughly match Excel's resolutions when it determines which format wins out. + */ + level: Level; + _partitions: Array<{ + color: string; + tokens: Array<{ type: 'num'; num: string } | { type: 'point'; value: string } | { type: 'frac'; num: string }>; + }>; +} + +interface DateInfo { + /** If any `y` or `b` operator was found in the pattern. */ + year: boolean; + + /** If any `m` operator was found in the pattern. */ + month: boolean; + + /** If any `d` operator was found in the pattern (including ones that emit weekday). */ + day: boolean; + + /** If any `h` operator was found in the pattern. */ + hours: boolean; + + /** If any `:m` operator was found in the pattern. */ + minutes: boolean; + /** If any `s` operator was found in the pattern. */ + seconds: boolean; + + /** Will be set to `12` if AM/PM operators are being used in the formatting string, else it will be set to `24`. */ + clockType: 24 | 12; +} + +interface Options { + /** + * A [BCP 47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) string tag. Locale default is english with a \u00a0 grouping symbol + * + * @default "" + */ + locale: LocaleTag; + + /** + * Should the formatter throw an error if a provided pattern is invalid. If not, a formatter will be constructed which only ever outputs an error string (see invalid in this table). + * @default true + */ + throws: boolean; + + /** + * The string emitted when no-throw mode fails to parse a pattern. + * @default "######" + */ + invalid: string; + + /** + * By default the formatters will emit [non-breaking-space](https://en.wikipedia.org/wiki/Non-breaking_space) rather than a regular space when emitting the formatted number. Setting this to false will make it use regular spaces instead. + * @default true + */ + nbsp: boolean; + + /** + * Simulate the Lotus 1-2-3 [1900 leap year bug](https://docs.microsoft.com/en-us/office/troubleshoot/excel/wrongly-assumes-1900-is-leap-year). It is a requirement in the Ecma OOXML specification so it is on by default. + * + * @default false + */ + leap1900: boolean; + + /** + * Should the formatter throw an error when trying to format a date that is out of bounds? + * + * @default false + */ + dateErrorThrows: boolean; + + /** + * Should the formatter switch to a General number format when trying to format a date that is out of bounds? + * @default true + */ + dateErrorNumber: boolean; + + /** + * The string emitted when a formatter fails to format a date that is out of bounds. + * @default "######" + */ + overflow: string; + + /** + * Extends the allowed range of dates from Excel bounds (1900–9999) to Google Sheet bounds (0–99999). + * @default true + */ + dateSpanLarge: boolean; + + /** + * Normally when date objects are used with the formatter, time zone is taken into account. This makes the formatter ignore the timezone offset. + * @default false + */ + ignoreTimezone: boolean; + + /** + * when using the numfmt.parseDate, numfmt.parseValue and numfmt.dateFromSerial functions, the output will be a Date object. + * @default false + */ + nativeDate: boolean; +} + +interface LocaleData { + // non-breaking space + group: string; + decimal: string; + positive: string; + negative: string; + percent: string; + exponent: string; + nan: string; + infinity: string; + ampm: [string, string]; + + // gregorian calendar + mmmm: string[]; + mmm: string[]; + dddd: string[]; + ddd: string[]; + + // islamic calendar + mmmm6: string[]; + mmm6: string[]; +} + +interface Formatter { + isDate(): boolean; + isPercent(): boolean; + isText(): boolean; + color(value: number): string; + info: Info; + dateInfo: DateInfo; + (value: number): string; +} + +type LocaleTag = + | 'zh-CN' + | 'zh' + | 'zh-TW' + | 'cs' + | 'da' + | 'nl' + | 'en' + | 'fi' + | 'fr' + | 'de' + | 'el' + | 'hu' + | 'is' + | 'id' + | 'it' + | 'ja' + | 'ko' + | 'nb' + | 'pl' + | 'pt' + | 'ru' + | 'sk' + | 'es' + | 'sv' + | 'th' + | 'tr'; + +export interface ParsedReturnType { + /** + * The parsed value. For dates, this will be an Excel style serial date unless the nativeDate option is used. + */ + v: number | string | boolean | Date; + /** + * (Optionally) the number format string of the input. This property will not be present if it amounts to the General format. + */ + z?: string; +} + +type _Year = number; +type _MonthIndex = number; +type _Date = number | undefined; +type _Hours = number | undefined; +type _Minutes = number | undefined; +type _Seconds = number | undefined; +type _Ms = number | undefined; +type DateValue = [_Year, _MonthIndex, _Date, _Hours, _Minutes, _Seconds, _Ms]; + +type ParseValue = number | boolean | DateValue | Date | null; + +export interface INumfmt { + (value: string, opt?: Options): Formatter; + + format(pattern: string, value: ParseValue, opt?: Partial): string; + + round(value: number, places?: number): number; + + /** + * @internal + */ + parseLocale(tag: LocaleTag): void; + getLocale(tag: LocaleTag): LocaleData | null; + + addLocale(data: Partial, tag: LocaleTag): void; + + isDate(format: string): boolean; + isPercent(format: string): boolean; + isText(format: string): boolean; + + getInfo(format: string): Info; + + parseValue(value: string, op?: Options): ParsedReturnType; + parseNumber(value: string, op?: Options): ParsedReturnType; + parseDate(value: string, op?: Options): ParsedReturnType; + parseTime(value: string, op?: Options): ParsedReturnType; + parseBool(value: string, op?: Options): ParsedReturnType; + + dateToSerial(value: Date | [number, number, number], opt?: Options): number | string; + + dateFromSerial(value: number, opt?: Options): Date; + + options(op: Partial): void; +} diff --git a/packages/engine-formula/src/basics/date.ts b/packages/engine-formula/src/basics/date.ts index 1de1719ec88..a0fb5c37738 100644 --- a/packages/engine-formula/src/basics/date.ts +++ b/packages/engine-formula/src/basics/date.ts @@ -313,11 +313,15 @@ export function getDateSerialNumberByObject(serialNumberObject: BaseValueObject) return ErrorValueObject.create(ErrorType.VALUE); } + if (dateSerial instanceof Date) { + dateSerial = excelDateTimeSerial(dateSerial); + } + if (+dateSerial < 0 || +dateSerial > 2958465) { // 2958465 = 9999/12/31 return ErrorValueObject.create(ErrorType.NUM); } - return dateSerial; + return +dateSerial; } else { const dateSerial = +serialNumberObject.getValue(); diff --git a/packages/engine-formula/src/functions/date/datevalue/index.ts b/packages/engine-formula/src/functions/date/datevalue/index.ts index 29df0f2bf60..de4996d50c7 100644 --- a/packages/engine-formula/src/functions/date/datevalue/index.ts +++ b/packages/engine-formula/src/functions/date/datevalue/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { isDate, parseFormattedValue } from '../../../basics/date'; +import { excelDateTimeSerial, isDate, parseFormattedValue } from '../../../basics/date'; import { ErrorType } from '../../../basics/error-type'; import type { BaseValueObject } from '../../../engine/value-object/base-value-object'; import { ErrorValueObject } from '../../../engine/value-object/base-value-object'; @@ -43,9 +43,14 @@ export class Datevalue extends BaseFunction { const value = `${dateTextObject.getValue()}`; const parsedDate = parseFormattedValue(value); if (parsedDate) { - const { v, z } = parsedDate; - if (isDate(z)) { - return NumberValueObject.create(Math.trunc(v)); + let { v, z } = parsedDate; + + if (z && isDate(z)) { + if (v instanceof Date) { + v = excelDateTimeSerial(v); + } + + return NumberValueObject.create(Math.trunc(+v)); } } } diff --git a/packages/engine-formula/src/functions/date/timevalue/index.ts b/packages/engine-formula/src/functions/date/timevalue/index.ts index 323934f3029..9fd9e92267d 100644 --- a/packages/engine-formula/src/functions/date/timevalue/index.ts +++ b/packages/engine-formula/src/functions/date/timevalue/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { isDate, parseFormattedValue } from '../../../basics/date'; +import { excelDateTimeSerial, isDate, parseFormattedValue } from '../../../basics/date'; import { ErrorType } from '../../../basics/error-type'; import { getFractionalPart } from '../../../engine/utils/math-kit'; import type { BaseValueObject } from '../../../engine/value-object/base-value-object'; @@ -44,10 +44,14 @@ export class Timevalue extends BaseFunction { const value = `${timeTextObject.getValue()}`; const parsedTime = parseFormattedValue(value); if (parsedTime) { - const { v, z } = parsedTime; + let { v, z } = parsedTime; - if (isDate(z)) { - return NumberValueObject.create(getFractionalPart(v)); + if (z && isDate(z)) { + if (v instanceof Date) { + v = excelDateTimeSerial(v); + } + + return NumberValueObject.create(getFractionalPart(+v)); } } } diff --git a/packages/sheets-numfmt/src/controllers/numfmt.editor.controller.ts b/packages/sheets-numfmt/src/controllers/numfmt.editor.controller.ts index 6fd2300c7b5..78ebb9b33ad 100644 --- a/packages/sheets-numfmt/src/controllers/numfmt.editor.controller.ts +++ b/packages/sheets-numfmt/src/controllers/numfmt.editor.controller.ts @@ -110,13 +110,13 @@ export class NumfmtEditorController extends Disposable { const type = getPatternType(numfmtCell.pattern); switch (type) { case 'scientific': - case 'percent': case 'currency': case 'grouped': case 'number': { const cell = context.worksheet.getCellRaw(row, col); return cell; } + case 'percent': case 'date': case 'time': case 'datetime': @@ -138,6 +138,7 @@ export class NumfmtEditorController extends Disposable { * @private * @memberof NumfmtService */ + private _initInterceptorEditorEnd() { if (!this._editorBridgeService) { return; @@ -159,6 +160,7 @@ export class NumfmtEditorController extends Disposable { ); const currentNumfmtType = (currentNumfmtValue && getPatternType(currentNumfmtValue.pattern)) ?? ''; const clean = () => { + // eslint-disable-next-line ts/no-unused-expressions currentNumfmtValue && this._collectEffectMutation.add( context.unitId, @@ -174,24 +176,24 @@ export class NumfmtEditorController extends Disposable { const content = String(value.v); - const dateInfo = numfmt.parseDate(content) || numfmt.parseTime(content); - const isTranslateDate = !!dateInfo; - if (isTranslateDate) { - if (dateInfo && dateInfo.z) { - const v = Number(dateInfo.v); + const numfmtInfo = numfmt.parseDate(content) || numfmt.parseTime(content) || numfmt.parseNumber(content); + + if (numfmtInfo) { + if (numfmtInfo.z) { + const v = Number(numfmtInfo.v); this._collectEffectMutation.add( context.unitId, context.subUnitId, context.row, context.col, { - pattern: dateInfo.z, + pattern: numfmtInfo.z, } ); return { ...value, v, t: CellValueType.NUMBER }; } } else if ( - ['date', 'time', 'datetime'].includes(currentNumfmtType) || + ['date', 'time', 'datetime', 'percent'].includes(currentNumfmtType) || !isNumeric(content) ) { clean();