svell_jerry vor 3 Jahren
Commit
46bbfe2ace

+ 5 - 0
.babelrc

@@ -0,0 +1,5 @@
+{
+    "presets": [
+        "@babel/env"
+    ]
+}

+ 11 - 0
.editorconfig

@@ -0,0 +1,11 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = crlf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+max_line_length = 120
+tab_width = 4
+trim_trailing_whitespace = true

+ 10 - 0
.eslintignore

@@ -0,0 +1,10 @@
+dist
+sakura
+
+.eslintrc.js
+api.js
+webpack.config.js
+webpack.dev.js
+webpack.prod.js
+package.json
+tsconfig.json

+ 64 - 0
.eslintrc.js

@@ -0,0 +1,64 @@
+const path = require('path');
+
+const resolve = (url) => path.resolve(__dirname, url);
+
+module.exports = {
+    "extends": [
+        'airbnb-base',
+        'airbnb-typescript/base'
+    ],
+    "env": {
+        "browser": true,
+        "es2021": true
+    },
+    "rules": {
+        "no-cond-assign":"off",
+        "quote-props":"off",
+        "@typescript-eslint/no-empty-function": "off",
+        "@typescript-eslint/brace-style":"off",
+        "no-restricted-globals": "off",
+        "prefer-object-spread": "off",
+        "guard-for-in":"off",
+        "no-multi-assign": "off",
+        "no-lonely-if": "off",
+        "@typescript-eslint/lines-between-class-members": "off",
+        "prefer-exponentiation-operator":"off",
+        "no-restricted-properties": "off",
+        "function-paren-newline": "off",
+        "no-labels": "off",
+        "@typescript-eslint/no-shadow": "off",
+        "no-nested-ternary": "off",
+        "no-debugger": "off",
+        "no-mixed-operators": "off",
+        "prefer-destructuring": "off",
+        "no-console": "off",
+        "import/export": "off",
+        "@typescript-eslint/quotes": "off",
+        "prefer-const": "off",
+        'linebreak-style': ["off", "windows"],
+        "prefer-rest-params": "off",
+        "max-classes-per-file": "off",
+        "max-len": "off",
+        "new-parens": "off",
+        "no-continue": "off",
+        "no-plusplus": "off",
+        "import/prefer-default-export": "off",
+        "@typescript-eslint/naming-convention": "off",
+        "@typescript-eslint/no-unused-vars": "off",
+        "@typescript-eslint/no-use-before-define": "off",
+        "import/no-cycle": "off",
+        "no-bitwise": 'off',
+        "default-case": "off",
+        "no-param-reassign": "off",
+        "class-methods-use-this": "off",
+        "consistent-return": "off",
+        "no-underscore-dangle": "off",
+        "no-restricted-syntax": "off",
+    },
+    "parserOptions": {
+        "warnOnUnsupportedTypeScriptVersion": false,
+        "ecmaVersion": 12,
+        "sourceType": "module",
+        "project": resolve("tsconfig.json")
+    }
+}

+ 95 - 0
.gitignore

@@ -0,0 +1,95 @@
+#ide
+*.DS_Store
+.idea
+.vscode
+
+#webpack build
+dist/
+
+#npm
+package-lock.json
+
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+#c++
+CMakeFiles
+cmake_install.cmake
+CMakeCache.txt
+Makefile
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented library generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules
+jspm_packages/
+
+# C++ build debug directories
+cmake-build-debug
+
+# TypeScript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL snapshot
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+# next.js build output
+.next
+
+#lerna-changelog
+.changelog
+
+#editor workspace file
+.idea/
+
+
+.DS_Store
+
+.history
+examples

+ 1 - 0
README.md

@@ -0,0 +1 @@
+### sheet ssf

+ 35 - 0
api-extractor.json

@@ -0,0 +1,35 @@
+{
+    "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+    "mainEntryPointFilePath": "<projectFolder>/lib/src/index.d.ts",
+    "bundledPackages": [],
+    "compiler": {},
+    "apiReport": {
+        "enabled": true,
+        "reportFolder": "<projectFolder>/etc/"
+    },
+    "docModel": {
+        "enabled": true
+    },
+    "dtsRollup": {
+        "enabled": true
+    },
+    "tsdocMetadata": {},
+    "messages": {
+        "compilerMessageReporting": {
+            "default": {
+                "logLevel": "warning"
+            }
+        },
+        "extractorMessageReporting": {
+            "ae-forgotten-export": {
+                "logLevel": "warning",
+                "addToApiReportFile": true
+            }
+        },
+        "tsdocMessageReporting": {
+            "default": {
+                "logLevel": "warning"
+            }
+        }
+    }
+}

+ 136 - 0
api.js

@@ -0,0 +1,136 @@
+const fs = require('fs');
+const path = require('path');
+const execa = require('execa');
+const rimraf = require('rimraf');
+const { spawn } = require('child_process');
+
+const { Extractor, ExtractorConfig } = require('@microsoft/api-extractor');
+
+try {
+  // clear folder
+  rimraf('./temp/*', () => {
+    execScript('npm', ['run', 'tsc'], () => {
+      // generate api
+      apiExtractor().then(
+        () => {
+          apiDocumenter();
+        },
+        () => {
+          apiDocumenter();
+        },
+      );
+    });
+  });
+} catch (error) {
+  console.log(error);
+}
+
+function apiExtractor() {
+  const apiExtractorJsonPath = path.join(__dirname, './api-extractor.json');
+
+  // Load and parse the api-extractor.json file
+  const extractorConfig = ExtractorConfig.loadFileAndPrepare(apiExtractorJsonPath);
+
+  return new Promise((resolve, reject) => {
+    try {
+      // Invoke API Extractor
+      const extractorResult = Extractor.invoke(extractorConfig, {
+        // Equivalent to the "--local" command-line parameter
+        localBuild: true,
+
+        // Equivalent to the "--verbose" command-line parameter
+        showVerboseMessages: true,
+      });
+
+      if (extractorResult.succeeded) {
+        console.log(`API Extractor completed successfully`);
+        process.exitCode = 0;
+
+        resolve();
+      } else {
+        console.error(
+          `API Extractor completed with ${extractorResult.errorCount} errors`
+                        + ` and ${extractorResult.warningCount} warnings`,
+        );
+        process.exitCode = 1;
+
+        reject();
+      }
+    } catch (p) {
+      console.log(`Exit code: ${p.exitCode}`);
+      console.log(`Error: ${p.stderr}`);
+
+      reject();
+    }
+  });
+}
+
+async function apiDocumenter() {
+  // create empty folders
+  let input = 'temp/input';
+  let markdown = 'temp/markdown';
+
+  // move folders
+  const oldInputPath = 'temp/sheet-format.api.json';
+  const newInputPath = 'temp/input/sheet-format.api.json';
+  const oldMarkdownPath = 'temp/sheet-format.api.md';
+  const newMarkdownPath = 'temp/markdown/sheet-format.api.md';
+
+  const mkmvInput = new Promise((resolve, reject) => {
+    mkdir(input, () => {
+      mv(oldInputPath, newInputPath, resolve);
+    });
+  });
+  const mkmvMarkdown = new Promise((resolve, reject) => {
+    mkdir(markdown, () => {
+      mv(oldMarkdownPath, newMarkdownPath, resolve);
+    });
+  });
+
+  Promise.all([mkmvInput, mkmvMarkdown]).then(() => {
+    process.chdir('temp');
+
+    // execute api-documenter
+    execScript('api-documenter', ['markdown']);
+  });
+}
+
+function mkdir(dir, cb) {
+  fs.mkdir(path.join(__dirname, dir), (err) => {
+    if (err) {
+      return console.error(err);
+    }
+    console.log(`Successfully create ${dir}!`);
+
+    cb && cb();
+  });
+}
+
+function mv(oldPath, newPath, cb) {
+  fs.rename(oldPath, newPath, (err) => {
+    if (err) throw err;
+    console.log(`Successfully move ${oldPath} to ${newPath}!`);
+    cb && cb();
+  });
+}
+
+function execScript(cmd, args = [], cb) {
+  // kick off process of listing files
+  const child = spawn(cmd, args, { shell: true });
+
+  // spit stdout to screen
+  child.stdout.on('data', (data) => {
+    process.stdout.write(data.toString());
+  });
+
+  // spit stderr to screen
+  child.stderr.on('data', (data) => {
+    process.stdout.write(data.toString());
+  });
+
+  child.on('close', (code) => {
+    console.log(`Finished with code ${code}`);
+
+    cb && cb();
+  });
+}

+ 100 - 0
build/webpack.config.js

@@ -0,0 +1,100 @@
+const path = require('path');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+
+const resolve = (url) => path.resolve(__dirname, "..", url);
+
+module.exports = {
+    entry: {
+        'sheet-format': resolve("src/index.ts"),
+    },
+    module: {
+        rules: [
+            {
+                test: /\.tsx?$/,
+                use: [
+                    {
+                        loader: 'babel-loader',
+                        options: {
+                            cacheDirectory: true,
+                        },
+                    },
+                    {
+                        loader: 'ts-loader'
+                    }
+                ]
+            },
+            {
+                test: /\.css$/,
+                use: [
+                    {
+                        loader: MiniCssExtractPlugin.loader,
+                        options: {
+                            publicPath: '../',
+                        },
+                    },
+                    {
+                        loader: 'css-loader',
+                    },
+                ],
+            },
+            {
+                test: /\.less$/,
+                use: [
+                    {
+                        loader: MiniCssExtractPlugin.loader,
+                        options: {
+                            publicPath: '../',
+                        },
+                    },
+                    {
+                        loader: 'css-loader',
+                    },
+                    {
+                        loader: 'less-loader',
+                    },
+                ],
+            },
+            {
+                test: /\.(png|svg|jpe?g|gif)$/i,
+                use: [
+                    {
+                        loader: 'url-loader',
+                        options: {
+                            limit: 18192,
+                            outputPath: 'img',
+                            name: '[name].[ext]?[hash]',
+                            esModule: false,
+                        },
+                    },
+                ],
+            },
+            {
+                test: /\.(woff|woff2|eot|ttf|otf)$/i,
+                use: [
+                    {
+                        loader: 'file-loader',
+                        options: {
+                            outputPath: 'font',
+                            esModule: false,
+                        },
+                    },
+                ],
+            },
+            {
+                test: /\.wasm$/,
+                type: 'webassembly/async',
+            },
+        ],
+    },
+    resolve: {
+        extensions: ['.ts', '.tsx', '.js', '.json'],
+        alias: {
+            '@': resolve('src'),
+        },
+    },
+    experiments: {
+        syncWebAssembly: true,
+        asyncWebAssembly: true,
+        topLevelAwait: true,
+    }
+};

+ 47 - 0
build/webpack.dev.js

@@ -0,0 +1,47 @@
+const { merge } = require('webpack-merge');
+const path = require('path');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const ESLintPlugin = require('eslint-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const ProgressBarWebpackPlugin = require('progress-bar-webpack-plugin');
+const common = require('./webpack.config.js');
+
+const resolve = (url) => path.resolve(__dirname, "..", url);
+
+module.exports = merge(common, {
+    mode: 'development',
+    devtool: 'inline-source-map',
+    plugins: [
+        new ESLintPlugin({
+            overrideConfigFile: resolve(".eslintrc.js"),
+            context: resolve("src"),
+            extensions: ['ts', 'js'],
+            fix: true,
+        }),
+        new ProgressBarWebpackPlugin(),
+        new HtmlWebpackPlugin({
+            template: './index.html',
+            title: 'sheet-format',
+            scriptLoading: 'blocking',
+        }),
+        new MiniCssExtractPlugin({
+            filename: 'css/[name].[contenthash].css',
+        }),
+    ],
+    output: {
+        filename: 'js/[name].[contenthash].js',
+        library: 'sheet-format',
+        libraryTarget: 'umd',
+    },
+    devServer: {
+        host: '127.0.0.1',
+        port: 'auto',
+        static: './',
+        hot: true,
+        bonjour: true,
+        client: {
+            progress: true,
+            overlay: true,
+        },
+    },
+});

+ 39 - 0
build/webpack.prod.js

@@ -0,0 +1,39 @@
+const { merge } = require('webpack-merge');
+const path = require('path');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const ESLintPlugin = require('eslint-webpack-plugin');
+const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+const ProgressBarWebpackPlugin = require('progress-bar-webpack-plugin');
+const common = require('./webpack.config.js');
+
+const resolve = (url) => path.resolve(__dirname, "..", url);
+
+module.exports = merge(common, {
+    mode: 'production',
+    devtool: 'source-map',
+    plugins: [
+        new ESLintPlugin({
+            overrideConfigFile: resolve(".eslintrc.js"),
+            context: resolve("src"),
+            extensions: ['ts', 'js'],
+            fix: true,
+        }),
+        new ProgressBarWebpackPlugin(),
+        new CleanWebpackPlugin(),
+        new HtmlWebpackPlugin({
+            template: './index.html',
+            title: 'sheet-format',
+            scriptLoading: 'blocking',
+        }),
+        new MiniCssExtractPlugin({
+            filename: 'css/[name].css',
+        }),
+    ],
+    output: {
+        filename: 'js/[name].js',
+        libraryTarget: 'umd',
+        library: 'sheet-format',
+        path: resolve('dist'),
+    },
+});

+ 10 - 0
index.html

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Title</title>
+</head>
+<body>
+
+</body>
+</html>

+ 6 - 0
jest.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  testEnvironment: 'jsdom',
+  collectCoverage: true,
+  preset: 'ts-jest',
+  coverageReporters: ['json', 'html'],
+};

+ 57 - 0
package.json

@@ -0,0 +1,57 @@
+{
+	"name": "sheet-format",
+	"version": "1.0.0",
+	"description": "",
+	"main": "./src/index.ts",
+	"author": "",
+	"license": "ISC",
+    "scripts": {
+        "dev": "webpack-dev-server --open --config build/webpack.dev.js",
+        "build": "webpack --config build/webpack.prod.js",
+        "tsc": "tsc",
+        "api": "node ./api.js -config ./api.config.json"
+    },
+	"devDependencies": {
+		"typescript": "4.5.2",
+        "@microsoft/api-extractor": "7.25.0",
+        "@types/offscreencanvas": "2019.6.4",
+
+		"@babel/core": "7.16.0",
+		"@babel/preset-env": "7.16.4",
+        "@babel/preset-typescript": "7.16.5",
+
+        "eslint": "8.4.1",
+        "eslint-config-airbnb-base": "15.0.0",
+        "eslint-plugin-import": "2.25.3",
+        "eslint-config-airbnb-typescript": "16.1.0",
+        "@typescript-eslint/eslint-plugin": "5.6.0",
+        "@typescript-eslint/parser": "5.6.0",
+
+        "assert":"2.0.0",
+        "buffer": "6.0.3",
+
+        "jest": "27.0.6",
+        "ts-jest": "27.1.0",
+        "@types/jest": "27.0.3",
+
+		"webpack": "5.73.0",
+		"webpack-cli": "4.10.0",
+		"webpack-dev-server": "4.9.2",
+
+		"babel-loader": "8.2.3",
+		"css-loader": "6.5.1",
+		"file-loader": "6.2.0",
+		"less-loader": "10.2.0",
+		"ts-loader": "9.2.6",
+		"url-loader": "4.1.1",
+
+		"clean-webpack-plugin": "4.0.0",
+		"eslint-webpack-plugin": "3.1.1",
+		"copy-webpack-plugin": "10.0.0",
+		"mini-css-extract-plugin": "2.4.5",
+		"webpack-merge": "5.8.0",
+		"html-webpack-plugin": "5.5.0",
+		"progress-bar-webpack-plugin": "2.1.0"
+	},
+	"dependencies": {}
+}

+ 8 - 0
src/core/clamp.ts

@@ -0,0 +1,8 @@
+export function clamp(number: number): number {
+  if (number === 0) {
+    return number;
+  }
+  const d = Math.ceil(Math.log10(number < 0 ? -number : number));
+  const mag = 10 ** (16 - Math.floor(d));
+  return Math.round(number * mag) / mag;
+}

+ 188 - 0
src/core/codeToLocale.ts

@@ -0,0 +1,188 @@
+export const codeToLocale = {
+  1078: 'af', // Afrikaans
+  1052: 'sq', // Albanian
+  1118: 'am', // Amharic
+  5121: 'ar_DZ', // Arabic - Algeria
+  15361: 'ar_BH', // Arabic - Bahrain
+  3073: 'ar_EG', // Arabic - Egypt
+  2049: 'ar_IQ', // Arabic - Iraq
+  11265: 'ar_JO', // Arabic - Jordan
+  13313: 'ar_KW', // Arabic - Kuwait
+  12289: 'ar_LB', // Arabic - Lebanon
+  4097: 'ar_LY', // Arabic - Libya
+  6145: 'ar_MA', // Arabic - Morocco
+  8193: 'ar_OM', // Arabic - Oman
+  16385: 'ar_QA', // Arabic - Qatar
+  1025: 'ar_SA', // Arabic - Saudi Arabia
+  10241: 'ar_SY', // Arabic - Syria
+  7169: 'ar_TN', // Arabic - Tunisia
+  14337: 'ar_AE', // Arabic - United Arab Emirates
+  9217: 'ar_YE', // Arabic - Yemen
+  1067: 'hy', // Armenian
+  1101: 'as', // Assamese
+  2092: 'az_AZ', // Azeri - Cyrillic
+  1068: 'az_AZ', // Azeri - Latin
+  1069: 'eu', // Basque
+  1059: 'be', // Belarusian
+  2117: 'bn', // Bengali - Bangladesh
+  1093: 'bn_IN', // Bengali - India
+  5146: 'bs', // Bosnian
+  1026: 'bg', // Bulgarian
+  1109: 'my', // Burmese
+  1027: 'ca', // Catalan
+  2052: 'zh_CN', // Chinese - China
+  3076: 'zh_HK', // Chinese - Hong Kong SAR
+  5124: 'zh_MO', // Chinese - Macau SAR
+  4100: 'zh_SG', // Chinese - Singapore
+  1028: 'zh_TW', // Chinese - Taiwan
+  1050: 'hr', // Croatian
+  1029: 'cs', // Czech
+  1030: 'da', // Danish
+  1125: 'dv', // Divehi; Dhivehi; Maldivian
+  2067: 'nl_BE', // Dutch - Belgium
+  1043: 'nl_NL', // Dutch - Netherlands
+  1126: 'bin', // Edo
+  3081: 'en_AU', // English - Australia
+  10249: 'en_BZ', // English - Belize
+  4105: 'en_CA', // English - Canada
+  9225: 'en_CB', // English - Caribbean
+  2057: 'en_GB', // English - Great Britain
+  16393: 'en_IN', // English - India
+  6153: 'en_IE', // English - Ireland
+  8201: 'en_JM', // English - Jamaica
+  5129: 'en_NZ', // English - New Zealand
+  13321: 'en_PH', // English - Phillippines
+  7177: 'en_ZA', // English - Southern Africa
+  11273: 'en_TT', // English - Trinidad
+  1033: 'en_US', // English - United States
+  12297: 'en_ZW', // English - Zimbabwe
+  1061: 'et', // Estonian
+  1071: 'mk', // FYRO Macedonia
+  1080: 'fo', // Faroese
+  1065: 'fa', // Farsi - Persian
+  1124: 'fil', // Filipino
+  1035: 'fi', // Finnish
+  2060: 'fr_BE', // French - Belgium
+  11276: 'fr_CM', // French - Cameroon
+  3084: 'fr_CA', // French - Canada
+  9228: 'fr_CG', // French - Congo
+  12300: 'fr_CI', // French - Cote d'Ivoire
+  1036: 'fr_FR', // French - France
+  5132: 'fr_LU', // French - Luxembourg
+  13324: 'fr_ML', // French - Mali
+  6156: 'fr_MC', // French - Monaco
+  14348: 'fr_MA', // French - Morocco
+  10252: 'fr_SN', // French - Senegal
+  4108: 'fr_CH', // French - Switzerland
+  7180: 'fr', // French - West Indies
+  1122: 'fy_NL', // Frisian - Netherlands
+  2108: 'gd_IE', // Gaelic - Ireland
+  1084: 'gd', // Gaelic - Scotland
+  1110: 'gl', // Galician
+  1079: 'ka', // Georgian
+  3079: 'de_AT', // German - Austria
+  1031: 'de_DE', // German - Germany
+  5127: 'de_LI', // German - Liechtenstein
+  4103: 'de_LU', // German - Luxembourg
+  2055: 'de_CH', // German - Switzerland
+  1032: 'el', // Greek
+  1140: 'gn', // Guarani - Paraguay
+  1095: 'gu', // Gujarati
+  1279: 'en', // HID (Human Interface Device)
+  1037: 'he', // Hebrew
+  1081: 'hi', // Hindi
+  1038: 'hu', // Hungarian
+  1039: 'is', // Icelandic
+  1136: 'ig_NG', // Igbo - Nigeria
+  1057: 'id', // Indonesian
+  1040: 'it_IT', // Italian - Italy
+  2064: 'it_CH', // Italian - Switzerland
+  1041: 'ja', // Japanese
+  1099: 'kn', // Kannada
+  1120: 'ks', // Kashmiri
+  1087: 'kk', // Kazakh
+  1107: 'km', // Khmer
+  1111: 'kok', // Konkani
+  1042: 'ko', // Korean
+  1088: 'ky', // Kyrgyz - Cyrillic
+  1108: 'lo', // Lao
+  1142: 'la', // Latin
+  1062: 'lv', // Latvian
+  1063: 'lt', // Lithuanian
+  2110: 'ms_BN', // Malay - Brunei
+  1086: 'ms_MY', // Malay - Malaysia
+  1100: 'ml', // Malayalam
+  1082: 'mt', // Maltese
+  1112: 'mni', // Manipuri
+  1153: 'mi', // Maori
+  1102: 'mr', // Marathi
+  1104: 'mn', // Mongolian
+  2128: 'mn', // Mongolian
+  1121: 'ne', // Nepali
+  1044: 'no_NO', // Norwegian - Bokml
+  2068: 'no_NO', // Norwegian - Nynorsk
+  1096: 'or', // Oriya
+  1045: 'pl', // Polish
+  1046: 'pt_BR', // Portuguese - Brazil
+  2070: 'pt_PT', // Portuguese - Portugal
+  1094: 'pa', // Punjabi
+  1047: 'rm', // Raeto-Romance
+  2072: 'ro_MO', // Romanian - Moldova
+  1048: 'ro_RO', // Romanian - Romania
+  1049: 'ru', // Russian
+  2073: 'ru_MO', // Russian - Moldova
+  1083: 'se', // Sami Lappish
+  1103: 'sa', // Sanskrit
+  3098: 'sr_SP', // Serbian - Cyrillic
+  2074: 'sr_SP', // Serbian - Latin
+  1072: 'st', // Sesotho (Sutu)
+  1074: 'tn', // Setsuana
+  1113: 'sd', // Sindhi
+  1115: 'si', // Sinhala; Sinhalese
+  1051: 'sk', // Slovak
+  1060: 'sl', // Slovenian
+  1143: 'so', // Somali
+  1070: 'sb', // Sorbian
+  11274: 'es_AR', // Spanish - Argentina
+  16394: 'es_BO', // Spanish - Bolivia
+  13322: 'es_CL', // Spanish - Chile
+  9226: 'es_CO', // Spanish - Colombia
+  5130: 'es_CR', // Spanish - Costa Rica
+  7178: 'es_DO', // Spanish - Dominican Republic
+  12298: 'es_EC', // Spanish - Ecuador
+  17418: 'es_SV', // Spanish - El Salvador
+  4106: 'es_GT', // Spanish - Guatemala
+  18442: 'es_HN', // Spanish - Honduras
+  2058: 'es_MX', // Spanish - Mexico
+  19466: 'es_NI', // Spanish - Nicaragua
+  6154: 'es_PA', // Spanish - Panama
+  15370: 'es_PY', // Spanish - Paraguay
+  10250: 'es_PE', // Spanish - Peru
+  20490: 'es_PR', // Spanish - Puerto Rico
+  1034: 'es_ES', // Spanish - Spain (Traditional)
+  14346: 'es_UY', // Spanish - Uruguay
+  8202: 'es_VE', // Spanish - Venezuela
+  1089: 'sw', // Swahili
+  2077: 'sv_FI', // Swedish - Finland
+  1053: 'sv_SE', // Swedish - Sweden
+  1114: 'syc', // Syriac
+  1064: 'tg', // Tajik
+  1097: 'ta', // Tamil
+  1092: 'tt', // Tatar
+  1098: 'te', // Telugu
+  1054: 'th', // Thai
+  1105: 'bo', // Tibetan
+  1073: 'ts', // Tsonga
+  1055: 'tr', // Turkish
+  1090: 'tk', // Turkmen
+  1058: 'uk', // Ukrainian
+  1056: 'ur', // Urdu
+  2115: 'uz_UZ', // Uzbek - Cyrillic
+  1091: 'uz_UZ', // Uzbek - Latin
+  1075: 've', // Venda
+  1066: 'vi', // Vietnamese
+  1106: 'cy', // Welsh
+  1076: 'xh', // Xhosa
+  1085: 'yi', // Yiddish
+  1077: 'zu', // Zulu
+};

+ 40 - 0
src/core/constants.ts

@@ -0,0 +1,40 @@
+export const u_YEAR: number = 2;
+export const u_MONTH: number = 2 ** 2;
+export const u_DAY: number = 2 ** 3;
+export const u_HOUR: number = 2 ** 4;
+export const u_MIN: number = 2 ** 5;
+export const u_SEC: number = 2 ** 6;
+export const u_DSEC: number = 2 ** 7;
+export const u_CSEC: number = 2 ** 8;
+export const u_MSEC: number = 2 ** 9;
+
+export const MIN_S_DATE: number = 0;
+export const MAX_S_DATE: number = 2958466;
+
+export const MIN_L_DATE: number = -694324;
+export const MAX_L_DATE: number = 35830291;
+
+export const EPOCH_1904: number = -1;
+export const EPOCH_1900: number = 1;
+export const EPOCH_1317: number = 6;
+
+export const _numchars: { [key: string]: string } = {
+  '#': '',
+  '0': '0',
+  '?': '\u00a0',
+};
+
+export const _sp_chars: { [key: string]: string } = {
+  '@': 'text',
+  '-': 'minus',
+  '+': 'plus',
+};
+
+export const indexColors: string[] = [
+  '#000', '#FFF', '#F00', '#0F0', '#00F', '#FF0', '#F0F', '#0FF', '#000', '#FFF',
+  '#F00', '#0F0', '#00F', '#FF0', '#F0F', '#0FF', '#800', '#080', '#008', '#880',
+  '#808', '#088', '#CCC', '#888', '#99F', '#936', '#FFC', '#CFF', '#606', '#F88',
+  '#06C', '#CCF', '#008', '#F0F', '#FF0', '#0FF', '#808', '#800', '#088', '#00F',
+  '#0CF', '#CFF', '#CFC', '#FF9', '#9CF', '#F9C', '#C9F', '#FC9', '#36F', '#3CC',
+  '#9C0', '#FC0',
+];

+ 41 - 0
src/core/dec2frac.ts

@@ -0,0 +1,41 @@
+const PRECISION: number = 1e-10;
+
+export function dec2frac(val: number, maxdigits_num: number, maxdigits_de: number): number[] {
+  const sign: number = (val < 0) ? -1 : 1;
+  const maxdigits_n: number = 10 ** (maxdigits_num || 2);
+  const maxdigits_d: number = 10 ** (maxdigits_de || 2);
+
+  let z: number = Math.abs(val);
+  let last_d: number = 0;
+  let last_n: number = 0;
+  let curr_n: number = 0;
+  let curr_d: number = 1;
+  let tmp: number;
+  let r: number[];
+
+  val = z;
+  if (val % 1 === 0) {
+    // handles exact integers including 0
+    r = [val * sign, 1];
+  } else if (val < 1e-19) {
+    r = [sign, 1e+19];
+  } else if (val > 1e+19) {
+    r = [1e+19 * sign, 1];
+  } else {
+    do {
+      z = 1 / (z - Math.floor(z));
+      tmp = curr_d;
+      curr_d = (curr_d * Math.floor(z)) + last_d;
+      last_d = tmp;
+      last_n = curr_n;
+      // round
+      curr_n = Math.floor(val * curr_d + 0.5);
+      if (curr_n >= maxdigits_n || curr_d >= maxdigits_d) {
+        return [sign * last_n, last_d];
+      }
+    }
+    while (Math.abs(val - (curr_n / curr_d)) >= PRECISION && z !== Math.floor(z));
+    r = [sign * curr_n, curr_d];
+  }
+  return r;
+}

+ 116 - 0
src/core/formatNumber.ts

@@ -0,0 +1,116 @@
+import { defaultLocale, getLocale } from './locale';
+import { runPart } from './runPart';
+import { parsePart, PartType } from './parsePart';
+import { OptionsData } from "./options";
+
+const default_text = parsePart('@');
+const default_color = 'black';
+
+function getPart(value: number, parts: PartType[]): PartType {
+  for (let pi = 0; pi < 3; pi++) {
+    const part = parts[pi];
+    if (part) {
+      let cond;
+      if (part.condition) {
+        const operator = part.condition[0];
+        const operand = part.condition[1];
+        if (operator === '=') { cond = (value === operand); }
+        else if (operator === '>') { cond = (value > operand); }
+        else if (operator === '<') { cond = (value < operand); }
+        else if (operator === '>=') { cond = (value >= operand); }
+        else if (operator === '<=') { cond = (value <= operand); }
+        else if (operator === '<>') { cond = (value !== operand); }
+      }
+      else {
+        cond = true;
+      }
+      if (cond) {
+        return part;
+      }
+    }
+  }
+}
+
+/**
+ * 颜色类型
+ * @param value
+ * @param parts
+ */
+export function color(value: number, parts: PartType[]): string {
+  if (typeof value !== 'number' || !isFinite(value)) {
+    const nan_color = parts[3] ? parts[3].color : default_text.color;
+    return nan_color || default_color;
+  }
+  const part = getPart(value, parts);
+  return part ? part.color || default_color : default_color;
+}
+
+/**
+ * 数字格式化
+ * @param value
+ * @param parts
+ * @param opts
+ */
+export function formatNumber(value: string | number, parts: PartType[], opts: OptionsData): string {
+  const l10n = getLocale(opts.locale);
+  // not a number?
+  const text_part = parts[3] ? parts[3] : default_text;
+  if (typeof value === 'boolean') {
+    value = value ? 'TRUE' : 'FALSE';
+  }
+  if (value == null) {
+    return '';
+  }
+  if (typeof value !== 'number') {
+    return runPart(value, text_part, opts, l10n);
+  }
+  // guard against non-finite numbers:
+  if (!isFinite(value)) {
+    const loc = l10n || defaultLocale;
+    if (isNaN(value)) { return loc.nan; }
+    return (value < 0 ? loc.negative : '') + loc.infinity;
+  }
+  // find and run the pattern part that applies to this number
+  const part = getPart(value, parts);
+  return part ? runPart(value, part, opts, l10n) : '';
+}
+
+/**
+ * 日期检查
+ * @param partitions
+ */
+export function isDate(partitions: PartType[]): boolean {
+  return !!(
+    (partitions[0] && partitions[0].date)
+        || (partitions[1] && partitions[1].date)
+        || (partitions[2] && partitions[2].date)
+        || (partitions[3] && partitions[3].date)
+  );
+}
+
+/**
+ * 文本检查
+ * @param partitions
+ */
+export function isText(partitions: PartType[]): boolean {
+  const [part1, part2, part3, part4] = partitions;
+  return !!(
+    (!part1 || part1.generated)
+        && (!part2 || part2.generated)
+        && (!part3 || part3.generated)
+        && (part4 && part4.text && !part4.generated)
+  );
+}
+
+/**
+ *
+ * @param partitions
+ */
+export function isPercent(partitions: PartType[]): boolean {
+  return !!(
+    (partitions[0] && partitions[0].percent)
+        || (partitions[1] && partitions[1].percent)
+        || (partitions[2] && partitions[2].percent)
+        || (partitions[3] && partitions[3].percent)
+  );
+}

+ 105 - 0
src/core/general.ts

@@ -0,0 +1,105 @@
+import { numdec } from './numdec';
+import { round } from './round';
+
+const fixLocale = (s, l10n) => s.replace(/\./, l10n.decimal);
+
+/**
+ *
+ * @param ret
+ * @param part
+ * @param value
+ * @param l10n
+ */
+export function general(ret, part, value, l10n) {
+  const int = value | 0;
+
+  // sign is emitted if there is no condition or
+  // if condition operator is one of [ '<>', '>=', '>' ]
+  const showSign = value < 0 && (!part.condition
+        || part.condition[0] === '<>'
+        || part.condition[0] === '>='
+        || part.condition[0] === '>'
+  );
+  if (typeof value === 'string') {
+    // special case
+    // [<-25]General;[>25]General;General;General
+    ret.push(value);
+  }
+  else if (value === int) {
+    if (showSign) {
+      ret.push(l10n.negative);
+    }
+    ret.push(Math.abs(int));
+  }
+  else {
+    if (showSign) {
+      ret.push(l10n.negative);
+    }
+    let exp = 0;
+    const v = Math.abs(value);
+
+    // FIXME: it is best if numdec returns all of these
+    if (v) { exp = Math.floor(Math.log10(v)); }
+    let n = (exp < 0) ? v * (10 ** -exp) : v / (10 ** exp);
+    if (n === 10) { n = 1; exp++; }
+
+    // The application shall attempt to display the full number
+    // up to 11 digits (inc. decimal point).
+    const num_dig = numdec(v);
+
+    const getExp = () => {
+      const x = Math.abs(exp);
+      let m;
+      if (n === 1) {
+        m = n;
+      }
+      else {
+        m = round(n, 5);
+      }
+      ret.push(
+        fixLocale(`${m}`, l10n),
+        l10n.exponent, (
+          exp < 0 ? l10n.negative : l10n.positive),
+        x < 10 ? '0' : '',
+        x,
+      );
+    };
+
+    if (exp >= -4 && exp <= -1) {
+      const o = v.toPrecision(10 + exp).replace(/0+$/, '');
+      ret.push(fixLocale(o, l10n));
+    }
+    else if (exp === 10) {
+      const o = v.toFixed(10)
+        .slice(0, 12)
+        .replace(/\.$/, '');
+      ret.push(fixLocale(o, l10n));
+    }
+    else if (Math.abs(exp) <= 9) {
+      const w = 11;
+      if (num_dig.total <= w) {
+        const o = round(v, 9).toFixed(num_dig.frac);
+        ret.push(fixLocale(o, l10n));
+      }
+      else if (exp === 9) {
+        ret.push(Math.floor(v));
+      }
+      else if (exp >= 0 && exp < 9) {
+        ret.push(round(v, 9 - exp));
+      }
+      else {
+        getExp();
+      }
+    }
+    else if (num_dig.total >= 12) {
+      getExp();
+    }
+    else if (Math.floor(v) === v) {
+      ret.push(Math.floor(v));
+    }
+    else {
+      ret.push(fixLocale(round(v, 9).toFixed(num_dig.frac), l10n));
+    }
+  }
+  return ret;
+}

+ 338 - 0
src/core/locale.ts

@@ -0,0 +1,338 @@
+import {
+  codeToLocale,
+} from '../internal';
+
+export interface LocaleData {
+  positive?: string,
+  decimal?: string,
+  negative?: string,
+  percent?: string,
+  nan?: string,
+  exponent?: string,
+  group?: string,
+  isDefault?: boolean,
+  infinity?: string,
+  colors?: string[],
+  ampm?: string[],
+  mmm6?: string[],
+  mmmm6?: string[],
+  mmm?: string[],
+  mmmm?: string[],
+  ddd?: string[],
+  dddd?: string[],
+}
+
+export interface LocaleType {
+  lang?: string,
+  language?: string,
+  codeset?: string,
+  modifier?: string,
+  territory?: string,
+}
+
+const REG_EXP_LOCALE = /^([a-z\d]+)(?:[_-]([a-z\d]+))?(?:\.([a-z\d]+))?(?:@([a-z\d]+))?$/i;
+
+const defaultData: LocaleData = {
+  positive: '+',
+  decimal: '.',
+  negative: '-',
+  percent: '%',
+  isDefault: false,
+  nan: 'NaN',
+  exponent: 'E',
+  group: ' ',
+  infinity: '∞',
+  colors: ['black', 'blue', 'cyan', 'green', 'magenta', 'red', 'white', 'yellow'],
+  ampm: ['AM', 'PM'],
+  mmm: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
+  mmmm: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
+  mmm6: ['Muh.', 'Saf.', 'Rab. I', 'Rab. II', 'Jum. I', 'Jum. II', 'Raj.', 'Sha.', 'Ram.', 'Shaw.', 'Dhuʻl-Q.', 'Dhuʻl-H.'],
+  mmmm6: ['Muharram', 'Safar', 'Rabiʻ I', 'Rabiʻ II', 'Jumada I', 'Jumada II', 'Rajab', 'Shaʻban', 'Ramadan', 'Shawwal', 'Dhuʻl-Qiʻdah', 'Dhuʻl-Hijjah'],
+  ddd: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+  dddd: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
+};
+
+const locales: LocaleData = defaultData;
+
+export function createLocale(data: LocaleData): LocaleData {
+  return Object.assign({}, defaultData, data);
+}
+
+export function resolveLocale(l4e: string | number): string {
+  if (typeof l4e === 'number') {
+    return codeToLocale[l4e & 0xffff] || null;
+  }
+  const wincode = parseInt(l4e, 16);
+  if (isFinite(wincode) && codeToLocale[wincode & 0xffff]) {
+    return codeToLocale[wincode & 0xffff] || null;
+  }
+  if (REG_EXP_LOCALE.test(l4e)) {
+    return l4e;
+  }
+  return null;
+}
+
+export function parseLocale(l4e: string): LocaleType {
+  const lm = REG_EXP_LOCALE.exec(l4e);
+  if (!lm) {
+    throw new SyntaxError(`Malformed locale: ${l4e}`);
+  }
+  return {
+    lang: lm[1] + (lm[2] ? `_${lm[2]}` : ''),
+    language: lm[1],
+    territory: lm[2] || '',
+    codeset: lm[3] || '',
+    modifier: lm[4] || '',
+  };
+}
+
+export function getLocale(l4e: string | number): LocaleData {
+  const tag = resolveLocale(l4e);
+  let obj: LocaleData = null;
+  if (tag) {
+    const c = parseLocale(tag);
+    obj = locales[c.lang] || locales[c.language] || null;
+  }
+  return obj;
+}
+
+export function addLocale(options: LocaleData, id: string | LocaleType): LocaleData {
+  // parse language tag
+  const c = typeof id === 'object' ? id : parseLocale(id);
+  // add the language
+  locales[c.lang] = createLocale(options);
+  // if "xx_YY" is added also create "xx" if it is missing
+  if (c.language !== c.lang && !locales[c.language]) {
+    locales[c.language] = createLocale(options);
+  }
+  return locales[c.lang];
+}
+
+export const defaultLocale = createLocale({ group: ',' });
+
+defaultLocale.isDefault = true;
+
+addLocale({
+  group: ',',
+  ampm: ['上午', '下午'],
+  mmmm: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
+  mmm: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
+  dddd: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
+  ddd: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
+}, 'zh_CN');
+
+addLocale({
+  group: ',',
+  nan: '非數值',
+  ampm: ['上午', '下午'],
+  mmmm: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
+  mmm: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
+  dddd: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
+  ddd: ['週日', '週一', '週二', '週三', '週四', '週五', '週六'],
+}, 'zh_TW');
+
+addLocale({
+  group: ',',
+  ampm: ['午前', '午後'],
+  mmmm: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
+  mmm: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
+  dddd: ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'],
+  ddd: ['日', '月', '火', '水', '木', '金', '土'],
+}, 'ja');
+
+addLocale({
+  group: ',',
+  ampm: ['오전', '오후'],
+  mmmm: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
+  mmm: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
+  dddd: ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'],
+  ddd: ['일', '월', '화', '수', '목', '금', '토'],
+}, 'ko');
+
+addLocale({
+  group: ',',
+  ampm: ['ก่อนเที่ยง', 'หลังเที่ยง'],
+  mmmm: ['มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน', 'พฤษภาคม', 'มิถุนายน', 'กรกฎาคม', 'สิงหาคม', 'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม'],
+  mmm: ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'],
+  dddd: ['วันอาทิตย์', 'วันจันทร์', 'วันอังคาร', 'วันพุธ', 'วันพฤหัสบดี', 'วันศุกร์', 'วันเสาร์'],
+  ddd: ['อา.', 'จ.', 'อ.', 'พ.', 'พฤ.', 'ศ.', 'ส.'],
+}, 'th');
+
+addLocale({
+  decimal: ',',
+  ampm: ['dop.', 'odp.'],
+  mmmm: ['ledna', 'února', 'března', 'dubna', 'května', 'června', 'července', 'srpna', 'září', 'října', 'listopadu', 'prosince'],
+  mmm: ['led', 'úno', 'bře', 'dub', 'kvě', 'čvn', 'čvc', 'srp', 'zář', 'říj', 'lis', 'pro'],
+  dddd: ['neděle', 'pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota'],
+  ddd: ['ne', 'po', 'út', 'st', 'čt', 'pá', 'so'],
+}, 'cs');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  mmmm: ['januar', 'februar', 'marts', 'april', 'maj', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'december'],
+  mmm: ['jan.', 'feb.', 'mar.', 'apr.', 'maj', 'jun.', 'jul.', 'aug.', 'sep.', 'okt.', 'nov.', 'dec.'],
+  dddd: ['søndag', 'mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag'],
+  ddd: ['søn.', 'man.', 'tir.', 'ons.', 'tor.', 'fre.', 'lør.'],
+}, 'da');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  ampm: ['a.m.', 'p.m.'],
+  mmmm: ['januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december'],
+  mmm: ['jan.', 'feb.', 'mrt.', 'apr.', 'mei', 'jun.', 'jul.', 'aug.', 'sep.', 'okt.', 'nov.', 'dec.'],
+  dddd: ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag'],
+  ddd: ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za'],
+}, 'nl');
+
+addLocale({
+  group: ',',
+}, 'en');
+
+addLocale({
+  decimal: ',',
+  nan: 'epäluku',
+  ampm: ['ap.', 'ip.'],
+  mmmm: ['tammikuuta', 'helmikuuta', 'maaliskuuta', 'huhtikuuta', 'toukokuuta', 'kesäkuuta', 'heinäkuuta', 'elokuuta', 'syyskuuta', 'lokakuuta', 'marraskuuta', 'joulukuuta'],
+  mmm: ['tammik.', 'helmik.', 'maalisk.', 'huhtik.', 'toukok.', 'kesäk.', 'heinäk.', 'elok.', 'syysk.', 'lokak.', 'marrask.', 'jouluk.'],
+  dddd: ['sunnuntaina', 'maanantaina', 'tiistaina', 'keskiviikkona', 'torstaina', 'perjantaina', 'lauantaina'],
+  ddd: ['su', 'ma', 'ti', 'ke', 'to', 'pe', 'la'],
+}, 'fi');
+
+addLocale({
+  group: ' ',
+  decimal: ',',
+  mmmm: ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'],
+  mmm: ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'],
+  dddd: ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'],
+  ddd: ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'],
+}, 'fr');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  mmmm: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
+  mmm: ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sept.', 'Okt.', 'Nov.', 'Dez.'],
+  dddd: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
+  ddd: ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'],
+}, 'de');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  ampm: ['π.μ.', 'μ.μ.'],
+  mmmm: ['Ιανουαρίου', 'Φεβρουαρίου', 'Μαρτίου', 'Απριλίου', 'Μαΐου', 'Ιουνίου', 'Ιουλίου', 'Αυγούστου', 'Σεπτεμβρίου', 'Οκτωβρίου', 'Νοεμβρίου', 'Δεκεμβρίου'],
+  mmm: ['Ιαν', 'Φεβ', 'Μαρ', 'Απρ', 'Μαΐ', 'Ιουν', 'Ιουλ', 'Αυγ', 'Σεπ', 'Οκτ', 'Νοε', 'Δεκ'],
+  dddd: ['Κυριακή', 'Δευτέρα', 'Τρίτη', 'Τετάρτη', 'Πέμπτη', 'Παρασκευή', 'Σάββατο'],
+  ddd: ['Κυρ', 'Δευ', 'Τρί', 'Τετ', 'Πέμ', 'Παρ', 'Σάβ'],
+}, 'el');
+
+addLocale({
+  decimal: ',',
+  ampm: ['de.', 'du.'],
+  mmmm: ['január', 'február', 'március', 'április', 'május', 'június', 'július', 'augusztus', 'szeptember', 'október', 'november', 'december'],
+  mmm: ['jan.', 'febr.', 'márc.', 'ápr.', 'máj.', 'jún.', 'júl.', 'aug.', 'szept.', 'okt.', 'nov.', 'dec.'],
+  dddd: ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek', 'szombat'],
+  ddd: ['V', 'H', 'K', 'Sze', 'Cs', 'P', 'Szo'],
+}, 'hu');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  ampm: ['f.h.', 'e.h.'],
+  mmmm: ['janúar', 'febrúar', 'mars', 'apríl', 'maí', 'júní', 'júlí', 'ágúst', 'september', 'október', 'nóvember', 'desember'],
+  mmm: ['jan.', 'feb.', 'mar.', 'apr.', 'maí', 'jún.', 'júl.', 'ágú.', 'sep.', 'okt.', 'nóv.', 'des.'],
+  dddd: ['sunnudagur', 'mánudagur', 'þriðjudagur', 'miðvikudagur', 'fimmtudagur', 'föstudagur', 'laugardagur'],
+  ddd: ['sun.', 'mán.', 'þri.', 'mið.', 'fim.', 'fös.', 'lau.'],
+}, 'is');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  mmmm: ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'],
+  mmm: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
+  dddd: ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'],
+  ddd: ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'],
+}, 'id');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  mmmm: ['gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'],
+  mmm: ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'],
+  dddd: ['domenica', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato'],
+  ddd: ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab'],
+}, 'it');
+
+addLocale({
+  decimal: ',',
+  ampm: ['a.m.', 'p.m.'],
+  mmmm: ['januar', 'februar', 'mars', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'desember'],
+  mmm: ['jan.', 'feb.', 'mar.', 'apr.', 'mai', 'jun.', 'jul.', 'aug.', 'sep.', 'okt.', 'nov.', 'des.'],
+  dddd: ['søndag', 'mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag'],
+  ddd: ['søn.', 'man.', 'tir.', 'ons.', 'tor.', 'fre.', 'lør.'],
+}, 'nb');
+
+addLocale({
+  decimal: ',',
+  mmmm: ['stycznia', 'lutego', 'marca', 'kwietnia', 'maja', 'czerwca', 'lipca', 'sierpnia', 'września', 'października', 'listopada', 'grudnia'],
+  mmm: ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru'],
+  dddd: ['niedziela', 'poniedziałek', 'wtorek', 'środa', 'czwartek', 'piątek', 'sobota'],
+  ddd: ['niedz.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.'],
+}, 'pl');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  mmmm: ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'],
+  mmm: ['jan.', 'fev.', 'mar.', 'abr.', 'mai.', 'jun.', 'jul.', 'ago.', 'set.', 'out.', 'nov.', 'dez.'],
+  dddd: ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'],
+  ddd: ['dom.', 'seg.', 'ter.', 'qua.', 'qui.', 'sex.', 'sáb.'],
+}, 'pt');
+
+addLocale({
+  decimal: ',',
+  nan: 'не число',
+  mmmm: ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'],
+  mmm: ['янв.', 'февр.', 'мар.', 'апр.', 'мая', 'июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.'],
+  dddd: ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота'],
+  ddd: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'],
+}, 'ru');
+
+addLocale({
+  decimal: ',',
+  mmmm: ['januára', 'februára', 'marca', 'apríla', 'mája', 'júna', 'júla', 'augusta', 'septembra', 'októbra', 'novembra', 'decembra'],
+  mmm: ['jan', 'feb', 'mar', 'apr', 'máj', 'jún', 'júl', 'aug', 'sep', 'okt', 'nov', 'dec'],
+  dddd: ['nedeľa', 'pondelok', 'utorok', 'streda', 'štvrtok', 'piatok', 'sobota'],
+  ddd: ['ne', 'po', 'ut', 'st', 'št', 'pi', 'so'],
+}, 'sk');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  ampm: ['a. m.', 'p. m.'],
+  mmmm: ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'],
+  mmm: ['ene.', 'feb.', 'mar.', 'abr.', 'may.', 'jun.', 'jul.', 'ago.', 'sept.', 'oct.', 'nov.', 'dic.'],
+  dddd: ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'],
+  ddd: ['dom.', 'lun.', 'mar.', 'mié.', 'jue.', 'vie.', 'sáb.'],
+}, 'es');
+
+addLocale({
+  decimal: ',',
+  ampm: ['fm', 'em'],
+  mmmm: ['januari', 'februari', 'mars', 'april', 'maj', 'juni', 'juli', 'augusti', 'september', 'oktober', 'november', 'december'],
+  mmm: ['jan.', 'feb.', 'mars', 'apr.', 'maj', 'juni', 'juli', 'aug.', 'sep.', 'okt.', 'nov.', 'dec.'],
+  dddd: ['söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag'],
+  ddd: ['sön', 'mån', 'tis', 'ons', 'tors', 'fre', 'lör'],
+}, 'sv');
+
+addLocale({
+  group: '.',
+  decimal: ',',
+  ampm: ['ÖÖ', 'ÖS'],
+  mmmm: ['Ocak', 'Şubat', 'Mart', 'Nisan', 'Mayıs', 'Haziran', 'Temmuz', 'Ağustos', 'Eylül', 'Ekim', 'Kasım', 'Aralık'],
+  mmm: ['Oca', 'Şub', 'Mar', 'Nis', 'May', 'Haz', 'Tem', 'Ağu', 'Eyl', 'Eki', 'Kas', 'Ara'],
+  dddd: ['Pazar', 'Pazartesi', 'Salı', 'Çarşamba', 'Perşembe', 'Cuma', 'Cumartesi'],
+  ddd: ['Paz', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt'],
+}, 'tr');

+ 77 - 0
src/core/numdec.ts

@@ -0,0 +1,77 @@
+import { round } from './round';
+
+const zero = {
+  total: 1,
+  sign: 0,
+  period: 0,
+  int: 1,
+  frac: 0,
+};
+
+// returns the count of digits (including - and .) need to represent the number
+export function numdec(value, incl_sign = true) {
+  const v = Math.abs(value);
+
+  // shortcut zero
+  if (!v) { return zero; }
+
+  const signSize = (incl_sign && value < 0) ? 1 : 0;
+  const intPart = Math.floor(v);
+  const intSize = Math.floor(Math.log10(v) + 1);
+  let periodSize = 0;
+  let fracSize = 0;
+
+  // is not an integer
+  if (intPart !== v) {
+    periodSize = 1;
+
+    // B: this has turned out to be much faster than all pure math
+    // based solutions I was able to come up with ¯\_(ツ)_/¯
+    const n = String(
+      round(
+        (intSize < 0)
+          ? v * (10 ** -intSize)
+          : v / (10 ** intSize),
+        15,
+      ),
+    );
+    let f = n.length;
+    let z = true;
+    let i = 0;
+    while (i <= n.length) {
+      if (n[i] === '.') {
+        // discount period
+        f--;
+        break;
+      }
+      else if (n[i] === '0' && z) {
+        // leading zeros before period are discounted
+        f--;
+      }
+      else {
+        // non-zero digit
+        z = false;
+      }
+      i++;
+    }
+    fracSize = f - intSize;
+
+    if (fracSize < 0) {
+      // the number is not representable [by Excel]
+      // this would be something like 1000.0000000000001
+      // it would normally get truncated to 15 significant figures and
+      // end up in the same place as the following does:
+      fracSize = 0;
+      periodSize = 0;
+    }
+  }
+
+  return {
+    total: signSize + Math.max(intSize, 1) + periodSize + fracSize,
+    digits: Math.max(intSize, 0) + fracSize,
+    sign: signSize,
+    period: periodSize,
+    int: Math.max(intSize, 1),
+    frac: fracSize,
+  };
+}

+ 57 - 0
src/core/options.ts

@@ -0,0 +1,57 @@
+export interface OptionsData {
+  overflow?: string,
+  dateErrorThrows?: boolean,
+  dateSpanLarge?: boolean,
+  dateErrorNumber?: boolean,
+  invalid?: string,
+  locale?: string,
+  leap1900?: boolean,
+  nbsp?: boolean,
+  throws?: boolean,
+  ignoreTimezone?: boolean,
+}
+
+const defaultOptions: OptionsData = {
+  // Overflow error string
+  overflow: '######', // dateErrorThrow needs to be off! [prev in locale]
+  // Should it throw when there is an overflow error?
+  dateErrorThrows: false,
+  // Should it emit a number is an overflow error? (Sheets does this)
+  dateErrorNumber: true, // dateErrorThrow needs to be off!
+  // Sheets mode (see #3)
+  dateSpanLarge: true,
+  // Simulate the Lotus 1-2-3 leap year bug
+  leap1900: true,
+  // Emit regular vs. non-breaking spaces
+  nbsp: true,
+  // Robust/throw mode
+  throws: true,
+  // What is emitted when robust mode fails to parse (###### currently)
+  invalid: '######',
+  // Locale
+  locale: '',
+  // Don't adjust dates to UTC when converting them to serial time
+  ignoreTimezone: false,
+};
+
+const globalOptions = Object.assign({}, defaultOptions);
+
+export function options(opts: OptionsData = {}): OptionsData {
+  // passing in a null will reset to defaults
+  if (opts === null) {
+    opts = defaultOptions;
+  }
+  if (opts) {
+    for (const key in opts) {
+      if (key in defaultOptions) {
+        const value = opts[key];
+        if (value == null) { // set back to default
+          globalOptions[key] = defaultOptions[key];
+        } else {
+          globalOptions[key] = value;
+        }
+      }
+    }
+  }
+  return { ...globalOptions };
+}

+ 584 - 0
src/core/parsePart.ts

@@ -0,0 +1,584 @@
+import {
+  resolveLocale,
+} from './locale';
+
+import {
+  u_YEAR,
+  u_MONTH,
+  u_DAY,
+  u_HOUR,
+  u_MIN,
+  u_SEC,
+  u_DSEC,
+  u_CSEC,
+  u_MSEC,
+  EPOCH_1900,
+  EPOCH_1317,
+  _numchars,
+  _sp_chars,
+  indexColors,
+} from './constants';
+
+export interface TokensType {
+  type?: string
+  value?: string
+  short?: boolean
+  used?:boolean
+  indeterminate?: boolean
+  size?: number
+  date?: number
+  pad?:number
+  num?: number[]
+  rule?:string
+  raw?: string
+  volatile?: boolean
+  plus?: boolean
+  decimals?: number
+}
+
+export interface PartType {
+  scale?: number
+  percent?: boolean
+  int_max?: number
+  generated?: boolean,
+  denominator?: number
+  num_min?:number
+  den_min?:number
+  text?: boolean
+  group_sec?: number
+  frac_max?: number
+  num_max?: number
+  den_max?: number
+  date?: number
+  exponential?: boolean
+  group_pri?: number,
+  exp_plus?: boolean
+  condition?: [string, number]
+  fractions?: boolean
+  grouping?: boolean
+  date_eval?: boolean
+  date_system?: number
+  dec_fractions?: boolean
+  sec_decimals?: number
+  pattern?: string
+  general?: boolean
+  integer?: boolean
+  clock?: number
+  locale?: string
+  color?: string
+  int_pattern?: number[]
+  frac_pattern?: number[]
+  man_pattern?: number[]
+  den_pattern?: number[]
+  num_pattern?: number[]
+  tokens?: TokensType[]
+  int_padding?: string
+  man_padding?: string
+  num_padding?: string
+  den_padding?: string
+}
+
+const _pattcache: {
+  [key: string]: string
+} = {};
+
+function minMaxPad(str: string, part: object, prefix: string): object {
+  part[`${prefix}_max`] = str.length;
+  part[`${prefix}_min`] = str.replace(/#/g, '').length;
+  return part;
+}
+
+function patternToPadding(ss: string): string {
+  if (!(ss in _pattcache)) {
+    const nch = [];
+    const chars = ss.replace(/^[#,]+/, '').replace(/[1-9]\d*/g, (m) => '?'.repeat(m.length));
+    for (let i = 0; i < chars.length; i++) {
+      const c = chars.charAt(i);
+      nch[i] = (c in _numchars) ? _numchars[c] : c;
+    }
+    _pattcache[ss] = nch.join('');
+  }
+  return _pattcache[ss];
+}
+
+/**
+ * 添加Token
+ * @param s
+ * @param tokens
+ */
+function add(s: TokensType | string, tokens: TokensType[]) {
+  // allow adding string tokens without wrapping
+  if (typeof s === 'string') {
+    s = s.replace(/ /g, _numchars['?']);
+    s = { type: 'string', value: s };
+  }
+  // automatically join adjacent string tokens
+  if (s.type === 'string' && tokens.length && tokens[tokens.length - 1].type === 'string') {
+    tokens[tokens.length - 1].value += s.value;
+  } else {
+    tokens.push(<TokensType> s);
+  }
+}
+
+export function parsePart(pattern: string): PartType {
+  const tokens = [];
+  const part: PartType = {
+    scale: 1,
+    percent: false,
+    text: false,
+    denominator: 0,
+    date: 0,
+    generated: false,
+    exponential: false,
+    exp_plus: false,
+    grouping: false,
+    date_eval: false,
+    locale: '',
+    condition: null,
+    date_system: null,
+    sec_decimals: 0,
+    general: false,
+    integer: false,
+    group_sec: 0,
+    fractions: false,
+    group_pri: 0,
+    color: '',
+    clock: 24,
+    int_padding: '',
+    man_padding: '',
+    num_padding: '',
+    den_padding: '',
+    dec_fractions: false,
+    pattern: '',
+    int_pattern: [],
+    frac_pattern: [],
+    man_pattern: [],
+    den_pattern: [],
+    num_pattern: [],
+    tokens,
+  };
+
+  let s = (`${pattern}`);
+  let current_pattern = 'int';
+  let part_over = false;
+  let last_number_chunk = null;
+  let date_chunks = [];
+  let last;
+  let have_locale = false;
+  let m: RegExpExecArray | string[];
+
+  while (s && !part_over) {
+    if ((m = /^General/i.exec(s))) {
+      part.general = true;
+      add({ type: 'general' }, tokens);
+    }
+
+    // new partition
+    else if ((current_pattern === 'int' && (m = /^[#?0]+(?:,[#?0]+)*/.exec(s))) || (current_pattern === 'den' && (m = /^[#?\d]+/.exec(s))) || (m = /^[#?0]+/.exec(s))) {
+      part[`${current_pattern}_pattern`].push(m[0]);
+      last_number_chunk = { type: current_pattern, num: m[0] };
+      add(last_number_chunk, tokens);
+    }
+
+    // vulgar fractions
+    else if ((m = /^\//.exec(s)) && part[`${current_pattern}_pattern`].length) {
+      if (!last_number_chunk) { // need to have a numerator present
+        throw new SyntaxError(`Missing a numerator in pattern ${pattern}`);
+      }
+      part.fractions = true;
+      // ... we just passed the numerator - correct that item
+      part.num_pattern.push(part[`${current_pattern}_pattern`].pop());
+      last_number_chunk.type = 'num';
+      // next up... the denominator
+      current_pattern = 'den';
+      add({ type: 'div' }, tokens);
+    }
+
+    else if ((m = /^,+/.exec(s))) {
+      // decimal scaling
+      // * must directly follow a "number character" [#0?] and
+      // * must not be followed by a number character
+      const followed_by_num = (s.charAt(1) in _numchars);
+      const following_num = (last.slice(-1) in _numchars);
+      if (following_num && (m[0].length > 1 || !followed_by_num)) {
+        part.scale = 0.001 ** m[0].length;
+      } else {
+        // regular comma
+        add(m[0], tokens);
+      }
+    }
+
+    else if ((m = /^;/.exec(s))) {
+      part_over = true;
+      break; // leave the ";" hanging
+    }
+
+    // handlers
+    else if ((m = /^[@+-]/.exec(s))) {
+      if (m[0] === '@') { part.text = true; }
+      add({ type: _sp_chars[m[0]] }, tokens);
+    }
+
+    // [h] [m] [s]
+    else if ((m = /^(?:\[(h+|m+|s+)\])/i.exec(s))) {
+      const token = m[1].toLowerCase();
+      const tok = token[0];
+      const bit = {
+        type: '', size: 0, date: 1, raw: m[0], pad: token.length,
+      };
+      if (tok === 'h') {
+        bit.size = u_HOUR;
+        bit.type = 'hour-elap';
+      } else if (tok === 'm') {
+        bit.size = u_MIN;
+        bit.type = 'min-elap';
+      } else {
+        bit.size = u_SEC;
+        bit.type = 'sec-elap';
+      }
+      // signal date calc and track smallest needed unit
+      part.date |= bit.size;
+      date_chunks.push(bit);
+      add(bit, tokens);
+    }
+
+    // Use Hijri calendar system
+    else if ((m = /^(?:B2)/i.exec(s))) {
+      // signal date system (ignored if defined with [$-xxx])
+      if (!have_locale) {
+        // TODO: B2 does more than this, it switches locale to [$-060401] (ar) which affects display (RTL)
+        part.date_system = EPOCH_1317;
+      }
+    }
+
+    // Use Gregorian calendar system
+    else if ((m = /^(?:B1)/i.exec(s))) {
+      // signal date system (ignored if defined with [$-xxx])
+      if (!have_locale) {
+        part.date_system = EPOCH_1900;
+      }
+    }
+
+    // hh:mm:ss YYYY-MM-DD
+    else if ((m = /^(?:([hHmMsSyYbBdDegG])\1*)/.exec(s))) {
+      // Excel is "mostly" case insensitive here but checks the last used date token
+      // if it was s or h, minutes is used – same is true if we hit m or s, and last is m
+      // m and mm are spurious, mmm is always month
+      const bit: TokensType = {
+        type: '', size: 0, date: 1, raw: m[0], pad: 0, indeterminate: false, used: false,
+      };
+      const token = m[0].toLowerCase();
+      const tok = token[0];
+
+      if (token === 'y' || token === 'yy') {
+        bit.size = u_YEAR;
+        bit.type = 'year-short';
+      } else if (tok === 'y' || tok === 'e') {
+        bit.size = u_YEAR;
+        bit.type = 'year';
+      } else if (token === 'b' || token === 'bb') {
+        bit.size = u_YEAR;
+        bit.type = 'b-year-short';
+      } else if (tok === 'b') {
+        bit.size = u_YEAR;
+        bit.type = 'b-year';
+      } else if (token === 'd' || token === 'dd') {
+        bit.size = u_DAY;
+        bit.type = 'day';
+        bit.pad = /dd/.test(token) ? 1 : 0;
+      } else if (token === 'ddd') {
+        bit.size = u_DAY;
+        bit.type = 'weekday-short';
+      } else if (tok === 'd') {
+        bit.size = u_DAY;
+        bit.type = 'weekday';
+      } else if (tok === 'h') {
+        bit.size = u_HOUR;
+        bit.type = 'hour';
+        bit.pad = /hh/i.test(token) ? 1 : 0;
+      } else if (tok === 'm') {
+        if (token.length === 3) {
+          bit.size = u_MONTH;
+          bit.type = 'monthname-short';
+        } else if (token.length === 5) {
+          bit.size = u_MONTH;
+          bit.type = 'monthname-single';
+        } else if (token.length >= 4) {
+          bit.size = u_MONTH;
+          bit.type = 'monthname';
+        }
+        // m or mm can be either minute or month based on context
+        const last_date_chunk = date_chunks[date_chunks.length - 1];
+        if (!bit.type && last_date_chunk && !last_date_chunk.used && (last_date_chunk.size & (u_HOUR | u_SEC))) {
+          // if this token follows hour or second, it is a minute
+          last_date_chunk.used = true;
+          bit.size = u_MIN;
+          bit.type = 'min';
+          bit.pad = /mm/.test(token) ? 1 : 0;
+        }
+        // if we still don't know, we treat as a month
+        // and defer, a later 'sec' token may switch it
+        if (!bit.type) {
+          bit.size = u_MONTH;
+          bit.type = 'month';
+          bit.pad = /mm/.test(token) ? 1 : 0;
+          bit.indeterminate = true;
+        }
+      } else if (tok === 's') {
+        bit.size = u_SEC;
+        bit.type = 'sec';
+        bit.pad = /ss/.test(token) ? 1 : 0;
+        // if last date chunk was m, flag this used
+        const last_date_chunk = date_chunks[date_chunks.length - 1];
+        if (last_date_chunk && last_date_chunk.size & u_MIN) {
+          bit.used = true;
+        }
+        // if last date chunk is undecided, we know that it is a minute
+        else if (last_date_chunk && last_date_chunk.indeterminate) {
+          delete last_date_chunk.indeterminate;
+          last_date_chunk.size = u_MIN;
+          last_date_chunk.type = 'min';
+          bit.used = true;
+        }
+      } else if (tok === 'g') {
+        // FIXME: Don't know what this does? (yet!)
+      }
+      // signal date calc and track smallest needed unit
+      part.date |= bit.size;
+      part.date_eval = true;
+      date_chunks.push(bit);
+      add(bit, tokens);
+    }
+
+    // AM/PM
+    // See: https://github.com/SheetJS/sheetjs/issues/676
+    else if ((m = /^(?:AM\/PM|am\/pm|A\/P)/.exec(s))) {
+      part.clock = 12;
+      // TEST: size is here is just a guess, can possibly detect this by rounding?
+      part.date |= u_HOUR;
+      part.date_eval = true;
+      add({ type: 'am', short: m[0] === 'A/P' }, tokens);
+    }
+
+    // Note: In locales where decimal symbol is set to "," Excel expects that rather than "."
+    // .0 .00 .000
+    else if (part.date && (m = /^\.0{1,3}/i.exec(s))) {
+      const dec = m[0].length - 1;
+      const size = [u_SEC, u_DSEC, u_CSEC, u_MSEC][dec];
+      part.date |= size;
+      part.date_eval = true;
+      part.sec_decimals = Math.max(part.sec_decimals, dec);
+      add({
+        type: 'subsec',
+        size,
+        decimals: dec,
+        date: 1,
+        raw: m[0],
+      }, tokens);
+    }
+
+    // escaped character, string
+    else if ((m = /^\\(.)/.exec(s)) || (m = /^"([^"]*?)"/.exec(s))) {
+      add(m[1], tokens);
+    }
+
+    // condition
+    else if ((m = /^\[(<[=>]?|>=?|=)\s*(-?[.\d]+)\]/.exec(s))) {
+      part.condition = [m[1], parseFloat(m[2])]; // [ operator, operand ]
+    }
+
+    // locale code -- we allow std. "en-US" style codes
+    // https://stackoverflow.com/questions/54134729/what-does-the-130000-in-excel-locale-code-130000-mean/54540455#54540455
+    else if ((m = /^\[\$([^\]]+)\]/.exec(s))) {
+      const bits = m[1].split('-');
+      const code = bits.length < 2 ? '' : bits[bits.length - 1];
+
+      const currency = bits[0];
+      if (currency) {
+        add(currency, tokens);
+      }
+
+      const l4e = resolveLocale(code);
+      if (l4e) { part.locale = l4e; }
+      const wincode = parseInt(code, 16);
+      if (isFinite(wincode) && (wincode & 0xff0000)) {
+        const cal = (wincode >> 16) & 0xff;
+        // only Hijri is supported atm.
+        if (cal === 6) { part.date_system = EPOCH_1317; }
+      }
+
+      have_locale = true; // ignore any B2 & B1 tokens
+    }
+
+    // color
+    else if ((m = /^\[(black|blue|cyan|green|magenta|red|white|yellow|color\s*(\d+))\]/i.exec(s))) {
+      part.color = m[2] ? (indexColors[parseInt(m[2], 10)] || '#000') : m[1].toLowerCase();
+    }
+
+    // WTF
+    else if ((m = /^\[(DBNum1|ENG|HIJ|JPN|TWN)\]/i.exec(s))) {
+      // ...
+    }
+
+    // percentage
+    else if ((m = /^%/.exec(s))) {
+      part.scale = 100;
+      part.percent = true;
+      add('%', tokens);
+    }
+
+    // skip width
+    else if ((m = /^_(\\.|.)/.exec(s))) {
+      // UNSUPPORTED: This does what Excel's TEXT function does in this case: Emits a space.
+      // It might be worth considering emitting U+200B (zero width space) for the most common pattern, "* ".
+      // That way a recipent of the string might still be able to make the spacing work by splitting the number?
+      add(' ', tokens);
+    }
+
+    // decimal fraction
+    else if ((m = /^\./.exec(s))) {
+      add({ type: 'point', value: m[0] }, tokens);
+      part.dec_fractions = true;
+      current_pattern = 'frac';
+    }
+
+    // exponent
+    else if ((m = /^[Ee]([+-]?|(?=[0#?]))/.exec(s))) {
+      // Exponent pattern requires symbol to directly follow "E" but the
+      // signature symbol, however, prefixes the first digit of the mantissa
+      part.exponential = true;
+      part.exp_plus = m[1] === '+';
+      current_pattern = 'man';
+      add({ type: 'exp', plus: (m[1] === '+') }, tokens);
+    }
+
+    // fill space with next char
+    else if ((m = /^\*(\\.|.)/.exec(s))) {
+      // UNSUPPORTED: This does what Excel's TEXT function does in this case: Emits nothing.
+    }
+
+    // characters that throw ... because reasons?
+    // Excel also throws on ÈÉÊËèéêëĒēĔĕĖėĘęĚěȄȅȆȇȨȩNnÑñŃńŅņŇňǸǹ...
+    // but there is limited point in replicating that behaviour
+    else if ((m = /^[BENn[]/.exec(s))) {
+      throw new SyntaxError(
+        `Unexpected char ${s.charAt(0)} in pattern ${pattern}`,
+      );
+    }
+
+    // characters are generally allowed to pass directly through
+    else {
+      m = [s[0]];
+      add(m[0], tokens);
+    }
+
+    // advance parser
+    last = m[0];
+    s = s.slice(m ? m[0].length : 1);
+  }
+
+  part.pattern = pattern.slice(0, pattern.length - s.length);
+
+  // Quickly determine if this pattern is condition only
+  // if so, then add String(value) but using the condition
+  if (/^((?:\[[^\]]+\])+)(;|$)/.test(part.pattern) && !/^\[(?:h+|m+|s+)\]/.test(part.pattern)) {
+    add({ type: 'text' }, tokens);
+  }
+
+  // Make sure we don't have an illegal pattern. We could support this but
+  // lets side with Excel here and don't because they make absolutely no sense.
+  if ((part.fractions && part.dec_fractions) || (part.fractions && part.exponential)) {
+    throw new SyntaxError(`Invalid pattern: ${part.pattern}`);
+  }
+
+  // parse number grouping
+  const ipatt = part.int_pattern.join('');
+  part.grouping = ipatt.indexOf(',') >= 0;
+  if (part.grouping) {
+    const si = ipatt.split(',');
+    const sl = si.length;
+    if (sl === 2) {
+      part.group_pri = si[1].length;
+      part.group_sec = part.group_pri;
+    }
+    // next block should only
+    else if (sl > 2) {
+      part.group_pri = si[sl - 1].length;
+      part.group_sec = si[sl - 2].length;
+    }
+  } else {
+    part.group_pri = 0;
+    part.group_sec = 0;
+  }
+
+  minMaxPad(ipatt.replace(/[,]/g, ''), part, 'int');
+  minMaxPad(part.frac_pattern.join(''), part, 'frac');
+  minMaxPad(part.man_pattern.join(''), part, 'man');
+
+  let num_pat = part.num_pattern.join('');
+  let den_pat = part.den_pattern.join('');
+
+  const enforce_padded = /\?/.test(den_pat) || /\?/.test(num_pat);
+
+  // numberical denominator padding type is inherited from numerator padding type
+  den_pat = den_pat.replace(/\d/g, enforce_padded ? '?' : '#');
+  if (enforce_padded) {
+    // this needs to be _before_ min/max
+    den_pat = den_pat.replace(/#$/g, '?');
+  }
+
+  minMaxPad(num_pat, part, 'num');
+  minMaxPad(den_pat, part, 'den');
+  if (enforce_padded) {
+    // this needs to be _after_ min/max
+    num_pat = num_pat.replace(/#$/g, '?');
+  }
+
+  part.int_padding = patternToPadding(part.int_pattern.join(''));
+  part.man_padding = patternToPadding(part.man_pattern.join(''));
+
+  part.num_padding = patternToPadding(num_pat);
+  part.den_padding = patternToPadding(den_pat);
+
+  if (part.den_pattern.length) {
+    // detect and set rounding factor for denominator
+    part.denominator = parseInt(part.den_pattern.join('').replace(/\D/g, ''), 10);
+  }
+  part.integer = !!part.int_pattern.join('').length;
+
+  // extra whitespace rules for vulgar fractions
+  if (part.fractions) {
+    // fragment bits affect surrounding whitespace
+    // if either bit is "#", the whitespace around it, and
+    // the div symbol, is removed if the bit is not shown
+    tokens.forEach((tok, i) => {
+      // is next token a "num", "den", or "div"?
+      const next = tokens[i + 1];
+      if (tok.type === 'string' && next) {
+        if (next.type === 'num') {
+          tok.rule = 'num+int';
+        }
+        else if (next.type === 'div') {
+          tok.rule = 'num';
+        }
+        else if (next.type === 'den') {
+          tok.rule = 'den';
+        }
+      }
+    });
+  }
+
+  // dates cannot blend with non-date tokens
+  // general cannot blend with non-date tokens
+  // -- this is doess not match excel 100% (it seems to allow , as a text token with general)
+  // -- excel also does something strange when mixing general with dates (but that can hardly be expected to work)
+  if ((part.date || part.general) && (part.int_pattern.length || part.frac_pattern.length || part.scale !== 1 || part.text)) {
+    throw new Error('Illegal format');
+  }
+
+  if (!part.date_system) {
+    part.date_system = EPOCH_1900;
+  }
+
+  return part;
+}

+ 119 - 0
src/core/parsePattern.ts

@@ -0,0 +1,119 @@
+import { resolveLocale } from './locale';
+import { parsePart, PartType } from './parsePart';
+
+export interface PatternType {
+  pattern?: string
+  locale?: string
+  error?: string
+  partitions?: PartType[]
+}
+
+export function parsePattern(pattern: string): PatternType {
+  const partitions = [];
+  let conditional = false;
+  let l10n_override;
+  let text_partition = null;
+
+  let p = pattern;
+  let more = 0;
+  let part = null;
+  let i = 0;
+  let conditions = 0;
+  do {
+    part = parsePart(p);
+    if (part.condition) {
+      conditions++;
+      conditional = true;
+    }
+    if (part.text) {
+      // only one text partition is allowed per pattern
+      if (text_partition) {
+        throw new Error('Unexpected partition');
+      }
+      text_partition = part;
+    }
+    if (part.locale) {
+      l10n_override = resolveLocale(part.locale);
+    }
+    partitions.push(part);
+    more = (p.charAt(part.pattern.length) === ';') ? 1 : 0;
+    p = p.slice(part.pattern.length + more);
+    i++;
+  }
+  while (more && i < 4 && conditions < 3);
+
+  // No more than 4 sections and only 2 conditional statements: "1;2;else;txt"
+  if (conditions > 2) {
+    throw new Error('Unexpected condition');
+  }
+  if (more) {
+    throw new Error('Unexpected partition');
+  }
+
+  // if this is not a conditional, then we ensure we have all 4 partitions
+  if (!conditional) {
+    // if we have less than 4 partitions - and one of them is .text, use it as the text one
+    if (partitions.length < 4 && text_partition) {
+      for (let pi = 0, pl = partitions.length; pi < pl; pi++) {
+        if (partitions[pi] === text_partition) {
+          partitions.splice(pi, 1);
+        }
+      }
+    }
+    // missing positive
+    if (partitions.length < 1 && text_partition) {
+      partitions[0] = parsePart('General');
+      partitions[0].generated = true;
+    }
+    // missing negative
+    if (partitions.length < 2) {
+      const part = parsePart(partitions[0].pattern);
+      // the volatile minus only happens if there is a single pattern
+      part.tokens.unshift({ type: 'minus', volatile: true });
+      part.generated = true;
+      partitions.push(part);
+    }
+    // missing zero
+    if (partitions.length < 3) {
+      const part = parsePart(partitions[0].pattern);
+      part.generated = true;
+      partitions.push(part);
+    }
+    // missing text
+    if (partitions.length < 4) {
+      if (text_partition) {
+        partitions.push(text_partition);
+      }
+      else {
+        const part = parsePart('@');
+        part.generated = true;
+        partitions.push(part);
+      }
+    }
+
+    partitions[0].condition = ['>', 0];
+    partitions[1].condition = ['<', 0];
+    partitions[2].condition = null;
+  }
+
+  return {
+    pattern,
+    partitions,
+    locale: l10n_override,
+  };
+}
+
+export function parseCatch(pattern: string): PatternType {
+  try {
+    return parsePattern(pattern);
+  }
+  catch (err) {
+    const errPart = { tokens: [{ type: 'error' }] };
+    return {
+      pattern,
+      locale: null,
+      partitions: [errPart, errPart, errPart, errPart],
+      error: err.message,
+    };
+  }
+}

+ 416 - 0
src/core/parseValue.ts

@@ -0,0 +1,416 @@
+import { dateFromSerial } from './serialDate';
+
+/*
+This is a list of the allowed date formats. The test file contains
+the full list of permuations and the resulting values and formats.
+
+Legend:
+  "-" - Date separator (any of "/" | "-" | " " | "."⁽¹⁾ | ", "⁽²⁾)
+  " " - Whitespace
+  "j" - Day without leading zero (1-31)
+  "d" - Day with leading zero (00-31)
+  "D" - Abbreviated day name ("Sun"-"Sat")
+  "l" - Full day name ("Sunday"-"Saturday")
+  "n" - Month without leading zero (1-12)
+  "m" - Month with leading zero (01-12)
+  "F" - Full month name ("Janary"-"December")
+  "M" - Abbreviated month name ("Jan"-"Dec")
+  "y" - Year without century (00-99)
+  "Y" - Year of our lord (1900-9999)
+  "x" - Time of day (all formats: "10 PM", "10:11:12", ...)
+
+¹ Only considered valid if there are three or more sections to the date.
+² Comma is only allowed if followed by a space.
+
+Time is appended to each of these as they are inserted into the
+collection of valid dates below.
+*/
+const okDateFormats = [
+  'd-F-y', 'd-F-Y', 'd-M-y', 'd-M-Y', 'F-d-y', 'F-d-Y', 'F-j-y', 'F-j-Y', 'j-F-y', 'j-F-Y',
+  'j-M-y', 'j-M-Y', 'M-d-y', 'M-d-Y', 'M-j-y', 'M-j-Y', 'm-d-y', 'm-d-Y', 'm-j-y', 'm-j-Y',
+  'n-d-y', 'n-d-Y', 'n-j-y', 'n-j-Y', 'y-F-d', 'y-F-j', 'y-M-d', 'y-M-j', 'Y-F-d', 'Y-F-j',
+  'Y-M-d', 'Y-m-d', 'Y-M-j', 'Y-m-j', 'Y-n-d', 'Y-n-j',
+  'M-d', 'M-j', 'd-F', 'd-M', 'n-d', 'n-j', 'j-F', 'j-M', 'M-Y', 'n-Y', 'm-d', 'F-d', 'm-j',
+  'F-j', 'm-Y', 'F-Y', 'Y-M', 'Y-n', 'Y-m', 'Y-F', 'Y-M',
+];
+
+// date formats are stored as a token-tree in a trie
+// for minimal looping and branching while parsing
+const dateTrie = {};
+function packDate(f, node) {
+  if (f) {
+    const char = f[0];
+    node[char] = node[char] || {};
+    packDate(f.slice(1), node[char]);
+  }
+  else {
+    node.$ = true;
+  }
+}
+okDateFormats.forEach((fmt) => {
+  // add date to token tree
+  packDate(fmt, dateTrie);
+  // add a variant of the date with time suffixed
+  // Excel allows time first, but Sheets and GRID do not
+  packDate(`${fmt} x`, dateTrie);
+  // add a variant of the date with weekdats pre/suffixed
+  packDate(`${fmt} l`, dateTrie);
+  packDate(`${fmt} l x`, dateTrie);
+  packDate(`l ${fmt}`, dateTrie);
+  packDate(`l ${fmt} x`, dateTrie);
+  packDate(`${fmt} D`, dateTrie);
+  packDate(`${fmt} D x`, dateTrie);
+  packDate(`D ${fmt}`, dateTrie);
+  packDate(`D ${fmt} x`, dateTrie);
+});
+
+/* eslint-disable object-property-newline */
+const monthsM = {
+  jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
+  jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
+};
+// note: May is missing here, it is always parsed as M
+const monthsF = {
+  january: 1, february: 2, march: 3, april: 4,
+  june: 6, july: 7, august: 8, september: 9,
+  october: 10, november: 11, december: 12,
+};
+const days = {
+  sunday: 'l', monday: 'l', tuesday: 'l', wednesday: 'l', thursday: 'l', friday: 'l', saturday: 'l',
+  sun: 'D', mon: 'D', tue: 'D', wed: 'D', thu: 'D', fri: 'D', sat: 'D',
+};
+/* eslint-enable */
+
+const currentYear = new Date().getUTCFullYear();
+
+export function parseNumber(str) {
+  // this horrifying expression assumes that we only need #,###.### and never #.###,###
+  const parts = /^([\s+%$(-]*)(((?:(?:\d[\d,]*)(?:\.\d*)?|(?:\.\d+)))([eE][+-]?\d+)?)([\s%$)]*)$/.exec(str);
+  if (parts) {
+    const [, prefix, number, numpart, exp, suffix] = parts;
+    let sign = 1;
+    let format = '';
+    let minus = false;
+    let openParen = false;
+    let closeParen = false;
+    let percent = false;
+    let dollar = false;
+    let dollarTailing = false;
+    let value = parseFloat(number.replace(/,/g, ''));
+    // is number ok?
+    if (!isFinite(value)) {
+      return null;
+    }
+    // is prefix ok?
+    for (let i = 0; i < prefix.length; i++) {
+      const char = prefix[i];
+      // only 1 occurance of these is allowed
+      if (char === '-') {
+        if (minus || openParen) { return null; }
+        minus = true;
+        sign = -1;
+      }
+      else if (char === '$') {
+        if (dollar) { return null; }
+        dollar = true;
+      }
+      else if (char === '(') {
+        if (openParen || minus) { return null; }
+        openParen = true;
+        sign = -1;
+      }
+      else if (char === '%') {
+        if (percent) { return null; }
+        percent = true;
+      }
+    }
+    // is suffix ok?
+    for (let i = 0; i < suffix.length; i++) {
+      const char = suffix[i];
+      // only 1 occurance of these is allowed
+      if (char === '$') {
+        if (dollar) { return null; }
+        dollar = true;
+        dollarTailing = true;
+      }
+      else if (char === ')') {
+        if (closeParen || !openParen) { return null; }
+        closeParen = true;
+      }
+      else if (char === '%') {
+        if (percent) { return null; }
+        percent = true;
+      }
+    }
+    if (exp) {
+      if (percent || dollar) {
+        return null;
+      }
+      // allow parens and minus, but not %$
+      format = '0.00E+00';
+    }
+    else if (percent) {
+      if (dollar) {
+        // Sheets allows this: $123% => $1.23 (Excel does not)
+        return null;
+      }
+      // numpart dictates how "deep" the format is: "0" vs "0.00"
+      format = numpart.includes('.') ? '0.00%' : '0%';
+      value *= 0.01;
+    }
+    else if (dollar) {
+      // numpart dictates how "deep" the format is: "0" vs "0.00"
+      if (dollarTailing) {
+        format = numpart.includes('.') ? '#,##0.00$' : '#,##0$';
+      }
+      else {
+        format = numpart.includes('.') ? '$#,##0.00' : '$#,##0';
+      }
+    }
+    else if (numpart.includes(',')) {
+      format = numpart.includes('.') ? '#,##0.00' : '#,##0';
+    }
+    // we may want to lower the fidelity of the number: +p.value.toFixed(13)
+    const ret = { v: value * sign, z: '' };
+    if (format) {
+      ret.z = format;
+    }
+    return ret;
+  }
+}
+
+export function isValidDate(y, m, d) {
+  // day can't be 0
+  if (d < 1) {
+    return false;
+  }
+  // month must be 1-12
+  if (m < 1 || m > 12) {
+    return false;
+  }
+  // february
+  if (m === 2) {
+    const isLeapYear = (((y % 4 === 0) && (y % 100 !== 0)) || (y % 400 === 0));
+    // 1900 is a leap year in Excel
+    const febDays = (isLeapYear || y === 1900) ? 29 : 28;
+    if (d > febDays) {
+      return false;
+    }
+  }
+  // test any other month
+  else if (
+    ((m === 4 || m === 6 || m === 9 || m === 11) && d > 30)
+        || ((m === 1 || m === 3 || m === 5 || m === 7 || m === 8 || m === 10 || m === 12) && d > 31)) {
+    return false;
+  }
+  return true;
+}
+
+const nextToken = (str, node, data) => {
+  const path = data.path || '';
+  const matchOrder = Object.keys(node);
+  for (let i = 0; i < matchOrder.length; i++) {
+    let r;
+    const t = matchOrder[i];
+    if (!node[t]) {
+      continue;
+    }
+    if (t === '$') {
+      // if string is done, then we can return
+      if (!str) {
+        r = data;
+      }
+    }
+    else if (t === '-') {
+      const m = /^(\s*([./-]|,\s)\s*|\s+)/.exec(str);
+      if (m) {
+        // const sep = (!m[1] || m[1] === '.' || m[1] === ',') ? ' ' : m[0].trim();
+        const sep = (m[1] === '-' || m[1] === '/' || m[1] === '.') ? m[1] : ' ';
+        // don't allow mixing date separators
+        if (!data.sep || data.sep === sep) {
+          const s = m[0].replace(/\s+/g, ' ');
+          r = nextToken(str.slice(m[0].length), node[t], { ...data, sep, path: path + s });
+          // r = nextToken(str.slice(m[0].length), node[t], { ...data, sep, path: path + sep });
+        }
+      }
+    }
+    else if (t === ' ') {
+      const m = /^[,.]?\s+/.exec(str);
+      if (m) {
+        const s = m[0].replace(/\s+/g, ' ');
+        r = nextToken(str.slice(m[0].length), node[t], { ...data, path: path + s });
+      }
+    }
+    else if (t === 'j' || t === 'd') {
+      const m = /^(0?[1-9]|1\d|2\d|3[01])\b/.exec(str);
+      if (m) {
+        r = nextToken(str.slice(m[0].length), node[t], { ...data, day: m[0], path: path + t });
+      }
+    }
+    else if (t === 'n' || t === 'm') {
+      const m = /^(0?[1-9]|1[012])\b/.exec(str);
+      if (m) {
+        r = nextToken(str.slice(m[0].length), node[t], {
+          ...data, month: +m[0], _mon: m[0], path: path + t,
+        });
+      }
+    }
+    else if (t === 'F' || t === 'M') {
+      const m = /^([a-z]{3,9})\b/i.exec(str);
+      const v = m && (t === 'F' ? monthsF : monthsM)[m[0].toLowerCase()];
+      if (v) {
+        r = nextToken(str.slice(m[0].length), node[t], {
+          ...data, month: v, _mon: m[0], path: path + t,
+        });
+      }
+    }
+    else if (t === 'l' || t === 'D') {
+      const m = /^([a-z]{3,9})\b/i.exec(str);
+      const v = m && days[m[0].toLowerCase()];
+      if (v === t) {
+        // the value is ignored
+        r = nextToken(str.slice(m[0].length), node[t], { ...data, path: path + t });
+      }
+    }
+    else if (t === 'y') {
+      const m = /^\d\d\b/.exec(str);
+      if (m) {
+        const y = (+m[0] >= 30) ? +m[0] + 1900 : +m[0] + 2000;
+        r = nextToken(str.slice(m[0].length), node[t], { ...data, year: y, path: path + t });
+      }
+    }
+    else if (t === 'Y') {
+      const m = /^\d\d\d\d\b/.exec(str);
+      if (m) {
+        r = nextToken(str.slice(m[0].length), node[t], { ...data, year: +m[0], path: path + t });
+      }
+    }
+    else if (t === 'x') {
+      const time = parseTime(str);
+      if (time) {
+        r = nextToken('', node[t], {
+          ...data, time: time.v, tf: time.z, path: path + t,
+        });
+      }
+    }
+    else {
+      throw new Error(`Unknown date token "${t}"`);
+    }
+    if (r) {
+      return r;
+    }
+  }
+};
+
+export function parseDate(str, opts) {
+  // possible shortcut: quickly dismiss if there isn't a number?
+  const date = nextToken(str.trim(), dateTrie, { path: '' });
+  if (date) {
+    // disallow matches where two tokens are separated by a period
+    if (date.sep === '.' && date.path.length === 3) {
+      return null;
+    }
+    const year = +(date.year ?? currentYear);
+    if (!date.day) {
+      date.day = 1;
+    }
+    // don't allow input of 31st apr, or 29th feb on non-leap years
+    if (!isValidDate(year, date.month, date.day)) {
+      return null;
+    }
+    let epoch = -Infinity;
+    if (year < 1900) {
+      return null;
+    }
+    if (year <= 1900 && date.month <= 2) {
+      epoch = 25568;
+    }
+    else if (year < 10000) {
+      epoch = 25569;
+    }
+    const value = (Date.UTC(year, date.month - 1, date.day) / 864e5) + epoch + (date.time || 0);
+    if (value >= 0 && value <= 2958465) {
+      const lead0 = (
+        // either has a leading zero
+        (date._mon[0] === '0' || date.day[0] === '0')
+                // both are 2-digits long
+                || (date._mon.length === 2 && date.day.length === 2)
+      );
+      // console.error(date.path);
+      const format = date.path.replace(/[jdlDnmMFyYx-]/g, (a) => {
+        if (a === 'j' || a === 'd') {
+          return lead0 ? 'dd' : 'd';
+        }
+        if (a === 'D') { return 'ddd'; }
+        if (a === 'l') { return 'dddd'; }
+        if (a === 'n' || a === 'm') {
+          return lead0 ? 'mm' : 'm';
+        }
+        if (a === 'M') { return 'mmm'; }
+        if (a === 'F') { return 'mmmm'; }
+        if (a === 'y') { return 'yy'; }
+        if (a === 'x') { return date.tf || ''; }
+        if (a === 'Y') { return 'yyyy'; }
+        return a;
+      });
+      if (opts && opts.nativeDate) {
+        return { v: dateFromSerial(value, opts), z: format };
+      }
+      return { v: value, z: format };
+    }
+  }
+  return null;
+}
+
+export function parseTime(str) {
+  const parts = /^\s*([10]?\d|2[0-4])(?::([0-5]\d|\d))?(?::([0-5]\d|\d))?(\.\d{1,10})?(?:\s*([AP])M?)?\s*$/i.exec(str);
+  if (parts) {
+    const [, h, m, s, f, am] = parts;
+    // don't allow milliseconds without seconds
+    if (f && !s) {
+      return null;
+    }
+    // single number must also include AM/PM part
+    if (!am && !m && !s) {
+      return null;
+    }
+    // AM/PM part must align with hours
+    let hrs = parseFloat(h) || 0;
+    if (am) {
+      // 00:00 AM - 12:00 AM
+      if (hrs >= 13) {
+        return null;
+      }
+      if (am[0] === 'p' || am[0] === 'P') {
+        hrs += 12;
+      }
+    }
+    const min = parseFloat(m) || 0;
+    const sec = parseFloat(s) || 0;
+    const mss = parseFloat(f) || 0;
+    return {
+      v: ((hrs * 60 * 60) + (min * 60) + sec + mss) / (60 * 60 * 24),
+      z: (
+        `${h.length === 2 ? 'hh' : 'h'
+        }:mm${
+          s ? ':ss' : ''
+        }${am ? ' AM/PM' : ''}`
+      ),
+    };
+  }
+  return null;
+}
+
+export function parseBool(str) {
+  if (/^\s*true\s*$/i.test(str)) {
+    return { v: true };
+  }
+  if (/^\s*false\s*$/i.test(str)) {
+    return { v: false };
+  }
+  return null;
+}
+
+export function parseValue(s, opts) {
+  return parseNumber(s) ?? parseDate(s, opts) ?? parseTime(s) ?? parseBool(s);
+}

+ 14 - 0
src/core/round.ts

@@ -0,0 +1,14 @@
+// Excel uses symmetric arithmetic rounding
+export function round(value: number, places : number = 0) {
+  if (typeof value !== 'number') {
+    return value;
+  }
+  if (value < 0) {
+    return -round(-value, places);
+  }
+  if (places) {
+    const p = 10 ** (places || 0) || 1;
+    return round(value * p, 0) / p;
+  }
+  return Math.round(value);
+}

+ 470 - 0
src/core/runPart.ts

@@ -0,0 +1,470 @@
+import { round } from './round';
+import { clamp } from './clamp';
+import { dec2frac } from './dec2frac';
+import { general } from './general';
+import { toYMD } from './toYMD';
+import { defaultLocale, LocaleData } from './locale';
+import {
+  u_HOUR,
+  u_MIN,
+  u_SEC,
+  u_DSEC,
+  u_CSEC,
+  u_MSEC,
+  EPOCH_1317,
+  MIN_S_DATE,
+  MAX_S_DATE,
+  MIN_L_DATE,
+  MAX_L_DATE,
+  _numchars,
+} from './constants';
+import { PartType } from "./parsePart";
+import { OptionsData } from "./options";
+
+const short_to_long = {
+  int: 'integer',
+  frac: 'fraction',
+  man: 'mantissa',
+  num: 'numerator',
+  den: 'denominator',
+};
+const DAYSIZE = 86400;
+
+const dateOverflows = (value, bigRange) => {
+  if (bigRange) {
+    return (value < MIN_L_DATE || value >= MAX_L_DATE);
+  }
+  return (value < MIN_S_DATE || value >= MAX_S_DATE);
+};
+
+/**
+ *
+ * @param value
+ * @param part
+ * @param opts
+ * @param l10n_
+ */
+export function runPart(value, part: PartType, opts: OptionsData, l10n_: LocaleData): string {
+  let mantissa = '';
+  let numerator = '';
+  let denominator = '';
+  let fraction = '';
+  let integer = '';
+  let exp = 0;
+
+  let date = value | 0;
+  let time = 0;
+  let year = 0;
+  let month = 1;
+  let day = 0;
+  let weekday = 0;
+  let hour = 0;
+  let minute = 0;
+  let second = 0;
+  let subsec = 0;
+
+  const l10n = l10n_ || defaultLocale;
+
+  // scale number
+  if (!part.text && isFinite(part.scale) && part.scale !== 1) {
+    value = clamp(value * part.scale);
+  }
+  // calc exponent
+  if (part.exponential) {
+    let v = Math.abs(value);
+    if (v) {
+      exp = Math.round(Math.log10(v));
+    }
+    if (part.int_max > 1) {
+      exp = Math.floor(exp / part.int_max) * part.int_max;
+    }
+    v = (exp < 0) ? v * (10 ** -exp) : v / (10 ** exp);
+    value = (value < 0) ? -v : v;
+    mantissa += Math.abs(exp);
+  }
+  // integer to text
+  if (part.integer) {
+    const i = Math.abs(round(value, part.fractions ? 1 : part.frac_max));
+    integer += (i < 1) ? '' : Math.floor(i);
+  }
+
+  // grouping
+  if (part.grouping) {
+    let gtmp = '';
+    let ipos = integer.length;
+    if (ipos > part.group_pri) {
+      ipos -= part.group_pri;
+      gtmp = l10n.group + integer.slice(ipos, ipos + part.group_pri) + gtmp;
+    }
+    while (ipos > part.group_sec) {
+      ipos -= part.group_sec;
+      gtmp = l10n.group + integer.slice(ipos, ipos + part.group_sec) + gtmp;
+    }
+    integer = ipos ? integer.slice(0, ipos) + gtmp : gtmp;
+  }
+
+  // fraction to text
+  if (part.dec_fractions) {
+    fraction = String(round(value, part.frac_max)).split('.')[1] || '';
+  }
+
+  // using vulgar fractions
+  let have_fraction = false;
+  if (part.fractions) {
+    const _dec = Math.abs(part.integer ? value % 1 : value);
+    if (_dec) {
+      have_fraction = true;
+      if (isFinite(part.denominator)) {
+        // predefined denominator
+        denominator += part.denominator;
+        numerator += round(_dec * part.denominator);
+        if (numerator === '0') {
+          numerator = '';
+          denominator = '';
+          have_fraction = false;
+          if (!integer) {
+            integer = '0';
+          }
+        }
+      } else {
+        const nmax = (part.integer) ? part.num_max : Infinity;
+        const frt = dec2frac(_dec, nmax, part.den_max);
+        numerator += frt[0];
+        denominator += frt[1];
+        if (part.integer) {
+          if (numerator === '0') {
+            if (!integer) {
+              integer = '0';
+            }
+            numerator = '';
+            denominator = '';
+            have_fraction = false;
+          }
+        }
+      }
+    }
+  }
+
+  // using date/time
+  if (part.date_eval && dateOverflows(value, opts.dateSpanLarge)) {
+    // if value is out of bounds and formatting is date Excel emits "#########" (full cell)
+    // this does not happen, if the only date tokens are elapsed time
+    // This copies the TEXT function which emits a #VALUE! error
+    if (opts.dateErrorThrows) {
+      throw new Error('Date out of bounds');
+    }
+    if (opts.dateErrorNumber) {
+      return general([], {}, value, l10n).join('');
+    }
+    return opts.overflow;
+  }
+  if (part.date) {
+    date = (value | 0);
+    const t = DAYSIZE * (value - date);
+    time = Math.floor(t); // in seconds
+
+    // "epsilon" correction
+    subsec = t - time;
+    if (Math.abs(subsec) < 1e-6) { // 0.000001
+      subsec = 0;
+    }
+    else if (subsec > 0.9999) {
+      subsec = 0;
+      time += 1;
+      if (time === DAYSIZE) {
+        time = 0;
+        date += 1;
+      }
+    }
+
+    // serial date/time to gregorian calendar
+    if (date || part.date_system) {
+      const dout = toYMD(value, part.date_system, opts.leap1900);
+      year = dout[0];
+      month = dout[1];
+      day = dout[2];
+    }
+    if (time || subsec) {
+      // round time based on smallest used unit
+      const minU = part.date & u_MSEC || part.date & u_CSEC || part.date & u_DSEC
+                || part.date & u_SEC || part.date & u_MIN || part.date & u_HOUR;
+      if (
+        (minU === u_MSEC && subsec > 0.9995)
+                || (minU === u_CSEC && subsec > 0.995)
+                || (minU === u_DSEC && subsec > 0.95)
+                || (minU === u_SEC && subsec >= 0.5)
+                || (minU === u_MIN && subsec >= 0.5)
+                || (minU === u_HOUR && subsec >= 0.5)
+      ) {
+        time++;
+        subsec = 0;
+      }
+      const x = (time < 0) ? DAYSIZE + time : time;
+      second = Math.floor(x) % 60;
+      minute = Math.floor(x / 60) % 60;
+      hour = Math.floor((x / 60) / 60) % 60;
+    }
+    weekday = (6 + date) % 7;
+  }
+
+  // integer padding
+  if (part.int_padding) {
+    integer = (part.int_padding.length === 1)
+      ? integer || part.int_padding
+      : part.int_padding.substring(0, part.int_padding.length - integer.length) + integer;
+  }
+  // numerator padding
+  if (part.num_padding) {
+    numerator = (part.num_padding.length === 1)
+      ? numerator || part.num_padding
+      : part.num_padding.substring(0, part.num_padding.length - numerator.length) + numerator;
+  }
+  // denominator padding
+  if (part.den_padding) {
+    denominator = (part.den_padding.length === 1)
+      ? denominator || part.den_padding
+      : denominator + part.den_padding.slice(denominator.length);
+  }
+  // mantissa padding
+  if (part.man_padding) {
+    const m_sign = (part.exp_plus) ? '+' : '';
+    mantissa = (part.man_padding.length === 1)
+      ? (exp < 0 ? '-' : m_sign) + (mantissa || part.man_padding)
+      : (exp < 0 ? '-' : m_sign) + part.man_padding.slice(0, part.man_padding.length - mantissa.length) + mantissa;
+  }
+
+  const ret = [];
+  let integer_bits_counter = 0;
+  const counter = {
+    int: 0, frac: 0, man: 0, num: 0, den: 0,
+  };
+  for (let ti = 0, tl = part.tokens.length; ti < tl; ti++) {
+    const tok = part.tokens[ti];
+    const len = tok.num ? tok.num.length : 0;
+
+    if (tok.type === 'string') {
+      // special rules may apply if next or prev is numerator or denominator
+      if (tok.rule) {
+        if (tok.rule === 'num') {
+          if (have_fraction) {
+            ret.push(tok.value);
+          }
+          else if (part.num_min > 0 || part.den_min > 0) {
+            ret.push(tok.value.replace(/./g, _numchars['?']));
+          }
+        }
+        else if (tok.rule === 'num+int') {
+          if (have_fraction && integer) {
+            ret.push(tok.value);
+          }
+          else if ((part.den_min > 0) && (integer || part.num_min)) {
+            ret.push(tok.value.replace(/./g, _numchars['?']));
+          }
+        }
+        else if (tok.rule === 'den') {
+          if (have_fraction) {
+            ret.push(tok.value);
+          }
+          else if (part.den_min > 0 || part.den_min > 0) {
+            ret.push(tok.value.replace(/./g, _numchars['?']));
+          }
+        }
+      }
+      else {
+        ret.push(tok.value);
+      }
+    }
+    else if (tok.type === 'error') {
+      // token used to define invalid pattern
+      ret.push(opts.invalid);
+    }
+    else if (tok.type === 'point') {
+      // Excel always emits a period: TEXT(0, "#.#") => "."
+      ret.push(part.date ? tok.value : l10n.decimal);
+    }
+    else if (tok.type === 'general') {
+      general(ret, part, value, l10n);
+    }
+    else if (tok.type === 'exp') {
+      ret.push(l10n.exponent);
+    }
+    else if (tok.type === 'minus') {
+      if (tok.volatile && part.date) {
+        // don't emit the prepended minus if this is a date
+      }
+      else if (tok.volatile && !part.fractions && (part.integer || part.dec_fractions)) {
+        // minus is only shown if there is a non-zero digit present
+        if ((integer && integer !== '0') || fraction) {
+          ret.push(l10n.negative);
+        }
+      }
+      else {
+        ret.push(l10n.negative);
+      }
+    }
+    else if (tok.type === 'plus') {
+      ret.push(l10n.positive);
+    }
+    else if (tok.type === 'text') {
+      ret.push(value);
+    }
+    else if (tok.type === 'div') {
+      if (have_fraction) {
+        ret.push('/');
+      }
+      else if (part.num_min > 0 || part.den_min > 0) {
+        ret.push(_numchars['?']);
+      }
+      else {
+        ret.push(_numchars['#']);
+      }
+    }
+    else if (tok.type === 'int') {
+      if (part.int_pattern.length === 1) { // number isn't fragmented
+        ret.push(integer);
+      }
+      else {
+        const c_s = (!integer_bits_counter)
+          ? Infinity
+          : part.int_pattern.join('').length - counter.int;
+        const c_e = (integer_bits_counter === part.int_pattern.length - 1)
+          ? 0
+          : part.int_pattern.join('').length - (counter.int + tok.num.length);
+        ret.push(integer.substring(integer.length - c_s, integer.length - c_e));
+        integer_bits_counter++;
+        counter.int += tok.num.length;
+      }
+    }
+    else if (tok.type === 'frac') {
+      const o = counter.frac;
+      for (let i = 0; i < len; i++) {
+        ret.push(fraction[i + o] || _numchars[tok.num[i]]);
+      }
+      counter.frac += len;
+    }
+    else if (tok.type in short_to_long) {
+      if (part[`${tok.type}_pattern`].length === 1) {
+        // number isn't fragmented
+        if (tok.type === 'int') {
+          ret.push(integer);
+        }
+        if (tok.type === 'frac') {
+          ret.push(fraction);
+        }
+        if (tok.type === 'man') {
+          ret.push(mantissa);
+        }
+        if (tok.type === 'num') {
+          ret.push(numerator);
+        }
+        if (tok.type === 'den') {
+          ret.push(denominator);
+        }
+      }
+      else {
+        ret.push(short_to_long[tok.type].slice(counter[tok.type], counter[tok.type] + len));
+        counter[tok.type] += len;
+      }
+    }
+    else if (tok.type === 'year') {
+      if (year < 0) { ret.push(l10n.negative); }
+      ret.push(String(Math.abs(year)).padStart(4, '0'));
+    }
+    else if (tok.type === 'year-short') {
+      const y = year % 100;
+      ret.push(y < 10 ? '0' : '', y);
+    }
+    else if (tok.type === 'month') {
+      ret.push((tok.pad && month < 10 ? '0' : ''), month);
+    }
+    else if (tok.type === 'monthname-single') {
+      // This is what Excel does.
+      // The Vietnamese list goes from ["Tháng 1", "Tháng 2", ... ] to [ "T", "T", ... ]
+      // Simplified Chinese goes from [ 1月, ... 9月, 10月, 11月, 12月 ] to [ 1, ... 9, 1, 1, 1 ]
+      if (part.date_system === EPOCH_1317) {
+        ret.push(l10n.mmmm6[month - 1].charAt(0));
+      }
+      else {
+        ret.push(l10n.mmmm[month - 1].charAt(0));
+      }
+    }
+    else if (tok.type === 'monthname-short') {
+      if (part.date_system === EPOCH_1317) {
+        ret.push(l10n.mmm6[month - 1]);
+      }
+      else {
+        ret.push(l10n.mmm[month - 1]);
+      }
+    }
+    else if (tok.type === 'monthname') {
+      if (part.date_system === EPOCH_1317) {
+        ret.push(l10n.mmmm6[month - 1]);
+      }
+      else {
+        ret.push(l10n.mmmm[month - 1]);
+      }
+    }
+    else if (tok.type === 'weekday-short') {
+      ret.push(l10n.ddd[weekday]);
+    }
+    else if (tok.type === 'weekday') {
+      ret.push(l10n.dddd[weekday]);
+    }
+    else if (tok.type === 'day') {
+      ret.push((tok.pad && day < 10 ? '0' : ''), day);
+    }
+    else if (tok.type === 'hour') {
+      const h = hour % part.clock || (part.clock < 24 ? part.clock : 0);
+      ret.push((tok.pad && h < 10 ? '0' : ''), h);
+    }
+    else if (tok.type === 'min') {
+      ret.push((tok.pad && minute < 10 ? '0' : ''), minute);
+    }
+    else if (tok.type === 'sec') {
+      ret.push((tok.pad && second < 10 ? '0' : ''), second);
+    }
+    else if (tok.type === 'subsec') {
+      ret.push(l10n.decimal);
+      // decimals is pre-determined by longest subsec token
+      // but the number emitted is per-token
+      const f = subsec.toFixed(part.sec_decimals);
+      ret.push(f.slice(2, 2 + tok.decimals));
+    }
+    else if (tok.type === 'am') {
+      const idx = hour < 12 ? 0 : 1;
+      if (tok.short && !l10n_) {
+        ret.push('AP'[idx]);
+      }
+      else {
+        ret.push(l10n.ampm[idx]);
+      }
+    }
+    else if (tok.type === 'hour-elap') {
+      if (value < 0) { ret.push(l10n.negative); }
+      const hh = (date * 24) + Math.floor(Math.abs(time) / (60 * 60));
+      ret.push(String(Math.abs(hh)).padStart(tok.pad, '0'));
+    }
+    else if (tok.type === 'min-elap') {
+      if (value < 0) { ret.push(l10n.negative); }
+      const mm = (date * 1440) + Math.floor(Math.abs(time) / 60);
+      ret.push(String(Math.abs(mm)).padStart(tok.pad, '0'));
+    }
+    else if (tok.type === 'sec-elap') {
+      if (value < 0) { ret.push(l10n.negative); }
+      const ss = (date * DAYSIZE) + Math.abs(time);
+      ret.push(String(Math.abs(ss)).padStart(tok.pad, '0'));
+    }
+    else if (tok.type === 'b-year') {
+      ret.push(year + 543);
+    }
+    else if (tok.type === 'b-year-short') {
+      const y = (year + 543) % 100;
+      ret.push(y < 10 ? '0' : '', y);
+    }
+  }
+  if (opts.nbsp) {
+    // can we detect ? or string tokens and only do this if needed?
+    return ret.join('');
+  }
+  return ret.join('').replace(/\u00a0/g, ' ');
+}

+ 54 - 0
src/core/serialDate.ts

@@ -0,0 +1,54 @@
+import { toYMD } from './toYMD';
+
+const floor = Math.floor;
+const DAYSIZE = 86400;
+
+export function dateToSerial(value, opts) {
+  let ts = null;
+  if (Array.isArray(value)) {
+    const [y, m, d, hh, mm, ss] = value;
+    ts = Date.UTC(y, m == null ? 0 : m - 1, d ?? 1, hh || 0, mm || 0, ss || 0);
+  }
+  // dates are changed to serial
+  else if (value instanceof Date) {
+    ts = value.getTime();
+    if (!opts || !opts.ignoreTimezone) {
+      ts -= (value.getTimezoneOffset() * 60 * 1000);
+    }
+  }
+  if (ts != null && isFinite(ts)) {
+    const d = (ts / 864e5);
+    return d - (d <= -25509 ? -25568 : -25569);
+  }
+  // everything else is passed through
+  return value;
+}
+
+export function dateFromSerial(value, opts) {
+  let date = (value | 0);
+  const t = DAYSIZE * (value - date);
+  let time = floor(t); // in seconds
+  // date "epsilon" correction
+  if ((t - time) > 0.9999) {
+    time += 1;
+    if (time === DAYSIZE) {
+      time = 0;
+      date += 1;
+    }
+  }
+  // serial date/time to gregorian calendar
+  const x = (time < 0) ? DAYSIZE + time : time;
+  const [y, m, d] = toYMD(value, 0, opts && opts.leap1900);
+  const hh = floor((x / 60) / 60) % 60;
+  const mm = floor(x / 60) % 60;
+  const ss = floor(x) % 60;
+  // return it as a native date object
+  if (opts && opts.nativeDate) {
+    const dt = new Date(0);
+    dt.setUTCFullYear(y, m - 1, d);
+    dt.setUTCHours(hh, mm, ss);
+    return dt;
+  }
+  // return the parts
+  return [y, m, d, hh, mm, ss];
+}

+ 74 - 0
src/core/toYMD.ts

@@ -0,0 +1,74 @@
+import {
+  EPOCH_1317,
+  EPOCH_1904,
+} from './constants';
+
+const floor = Math.floor;
+
+// https://www.codeproject.com/Articles/2750/Excel-Serial-Date-to-Day-Month-Year-and-Vice-Versa
+export function toYMD_1900(ord, leap1900 = true) {
+  if (leap1900 && ord >= 0) {
+    if (ord === 0) {
+      return [1900, 1, 0];
+    }
+    if (ord === 60) {
+      return [1900, 2, 29];
+    }
+    if (ord < 60) {
+      return [1900, (ord < 32 ? 1 : 2), ((ord - 1) % 31) + 1];
+    }
+  }
+  let l = ord + 68569 + 2415019;
+  const n = floor((4 * l) / 146097);
+  l -= floor((146097 * n + 3) / 4);
+  const i = floor((4000 * (l + 1)) / 1461001);
+  l = l - floor((1461 * i) / 4) + 31;
+  const j = floor((80 * l) / 2447);
+  const nDay = l - floor((2447 * j) / 80);
+  l = floor(j / 11);
+  const nMonth = j + 2 - (12 * l);
+  const nYear = 100 * (n - 49) + i + l;
+  return [nYear | 0, nMonth | 0, nDay | 0];
+}
+
+export function toYMD_1904(ord) {
+  return toYMD_1900(ord + 1462);
+}
+
+// https://web.archive.org/web/20080209173858/https://www.microsoft.com/globaldev/DrIntl/columns/002/default.mspx
+// > [algorithm] is used in many Microsoft products, including all operating systems that
+// > support Arabic locales, Microsoft Office, COM, Visual Basic, VBA, and SQL Server 2000.
+export function toYMD_1317(ord) {
+  if (ord === 60) {
+    throw new Error('#VALUE!');
+  }
+  if (ord <= 1) {
+    return [1317, 8, 29];
+  }
+  if (ord < 60) {
+    return [1317, (ord < 32 ? 9 : 10), 1 + ((ord - 2) % 30)];
+  }
+  const y = 10631 / 30;
+  const shift1 = 8.01 / 60;
+  let z = ord + 466935;
+  const cyc = floor(z / 10631);
+  z -= 10631 * cyc;
+  const j = floor((z - shift1) / y);
+  z -= floor(j * y + shift1);
+  const m = floor((z + 28.5001) / 29.5);
+  if (m === 13) {
+    return [30 * cyc + j, 12, 30];
+  }
+  return [30 * cyc + j, m, z - floor(29.5001 * m - 29)];
+}
+
+export function toYMD(ord, system = 0, leap1900 = true) {
+  const int = floor(ord);
+  if (system === EPOCH_1317) {
+    return toYMD_1317(int);
+  }
+  if (system === EPOCH_1904) {
+    return toYMD_1904(int);
+  }
+  return toYMD_1900(int, leap1900);
+}

+ 125 - 0
src/index.ts

@@ -0,0 +1,125 @@
+import {
+  formatNumber,
+  parseLocale,
+  isDate,
+  isPercent,
+  isText,
+  getLocale,
+  addLocale,
+  round,
+  dec2frac,
+  options,
+  codeToLocale,
+  color,
+  parsePattern,
+  parseCatch,
+  dateToSerial,
+  parseNumber,
+  parseDate,
+  parseTime,
+  parseBool,
+  parseValue,
+  dateFromSerial,
+  OptionsData,
+  PatternType,
+} from './internal';
+
+export interface FormatterType {
+  (value: number, opts?: OptionsData): void;
+  color(value, ops?): string;
+  isDate(): boolean;
+  isText(): boolean;
+  isPercent(): boolean;
+}
+
+const _cache: {
+  [key: string]: PatternType
+} = {};
+
+function getFormatter(parseData: PatternType, initOpts: OptionsData = {}): FormatterType {
+  const { pattern, partitions, locale } = parseData;
+
+  const getRuntimeOptions = (opts) => {
+    const runOpts = Object.assign({}, options(), initOpts, opts);
+    if (locale) {
+      runOpts.locale = locale;
+    }
+    return runOpts;
+  };
+
+  const formatter = (value, opts) => {
+    const o = getRuntimeOptions(opts);
+    return formatNumber(dateToSerial(value, o), partitions, o);
+  };
+  formatter.color = (value, opts = {}) => {
+    const o = getRuntimeOptions(opts);
+    return color(dateToSerial(value, o), partitions);
+  };
+  formatter.isPercent = () => isPercent(partitions);
+  formatter.isDate = () => isDate(partitions);
+  formatter.isText = () => isText(partitions);
+  formatter.pattern = pattern;
+  if (parseData.error) {
+    formatter.error = parseData.error;
+  }
+  formatter.options = getRuntimeOptions;
+  formatter.locale = locale || (initOpts && initOpts.locale) || '';
+  return Object.freeze(formatter);
+}
+
+function numfmt(pattern: string, opts: OptionsData = {}): FormatterType {
+  if (!pattern) {
+    pattern = 'General';
+  }
+  let parseData = null;
+  if (_cache[pattern]) {
+    parseData = _cache[pattern];
+  }
+  else {
+    const constructOpts = Object.assign({}, options(), opts);
+    parseData = constructOpts.throws
+      ? parsePattern(pattern)
+      : parseCatch(pattern);
+    if (!parseData.error) {
+      _cache[pattern] = parseData;
+    }
+  }
+  return getFormatter(parseData, opts);
+}
+
+numfmt.isDate = (d: string) => numfmt(d, { throws: false }).isDate();
+numfmt.isPercent = (d: string) => numfmt(d, { throws: false }).isPercent();
+numfmt.isText = (d: string) => numfmt(d, { throws: false }).isText();
+
+numfmt.dateToSerial = dateToSerial;
+numfmt.dateFromSerial = dateFromSerial;
+numfmt.options = options;
+numfmt.dec2frac = dec2frac;
+numfmt.round = round;
+numfmt.codeToLocale = codeToLocale;
+numfmt.getLocale = getLocale;
+numfmt.parseLocale = parseLocale;
+numfmt.addLocale = (options, l4e) => {
+  const c = parseLocale(l4e);
+  // when locale is changed, expire all cached patterns
+  delete _cache[c.lang];
+  delete _cache[c.language];
+  return addLocale(options, c);
+};
+
+// SSF interface compatibility
+function format(pattern, value, l4e, noThrows = false) {
+  const opts = (l4e && typeof l4e === 'object') ? l4e : { locale: l4e, throws: !noThrows };
+  return numfmt(pattern, opts)(dateToSerial(value, opts), opts);
+}
+numfmt.format = format;
+numfmt.is_date = numfmt.isDate;
+numfmt.parseNumber = parseNumber;
+numfmt.parseDate = parseDate;
+numfmt.parseTime = parseTime;
+numfmt.parseBool = parseBool;
+numfmt.parseValue = parseValue;
+
+export {
+  numfmt,
+};

+ 16 - 0
src/internal.ts

@@ -0,0 +1,16 @@
+export * from "./core/clamp";
+export * from "./core/codeToLocale";
+export * from "./core/constants";
+export * from "./core/dec2frac";
+export * from "./core/formatNumber";
+export * from "./core/general";
+export * from "./core/locale";
+export * from "./core/numdec";
+export * from "./core/options";
+export * from "./core/parsePart";
+export * from "./core/parsePattern";
+export * from "./core/parseValue";
+export * from "./core/round";
+export * from "./core/runPart";
+export * from "./core/serialDate";
+export * from "./core/toYMD";

+ 38 - 0
test/numfmt.test.ts

@@ -0,0 +1,38 @@
+import { numfmt } from "../src/index";
+
+test('numfmt custom', () => {
+  const formatter = numfmt("[green]#,##0;[red]-#,##0");
+  expect(formatter(100)).toEqual('100');
+});
+
+test('numfmt color', () => {
+  const formatter = numfmt("[green]#,##0;[red]-#,##0");
+  expect(formatter.color(-10)).toEqual('red');
+  expect(formatter.color(10)).toEqual('green');
+});
+
+test('numfmt number', () => {
+  const formatter = numfmt("[green]#,##0;[red]-#,##0");
+  const color = formatter.color(-10);
+  expect(color).toEqual('red');
+});
+
+test('numfmt date', () => {
+  const formatter = numfmt('yyyy"年"m"月"d"日";@');
+  expect(formatter(0)).toEqual('1900年1月0日');
+});
+
+test('numfmt time', () => {
+  const formatter = numfmt('[$-409]h:mm:ss AM/PM;@');
+  expect(formatter(12)).toEqual('12:00:00 AM');
+});
+
+test('numfmt currency', () => {
+  const formatter = numfmt('¥#,##0.00;[red]¥#,##0.00');
+  expect(formatter(1.00)).toEqual('¥1.00');
+});
+
+test('numfmt percentage', () => {
+  const formatter = numfmt("0.00%");
+  expect(formatter(0.1)).toEqual('10.00%');
+});

+ 29 - 0
tsconfig.json

@@ -0,0 +1,29 @@
+{
+    "compilerOptions": {
+        "module": "commonjs",
+        "target": "ES2015",
+        "lib": [
+            "ScriptHost",
+            "DOM",
+            "ES2015",
+            "ES2016",
+            "ES2017",
+            "ES2018",
+            "ES2019",
+            "ES2020",
+            "ES2021",
+            "ESNext"
+        ],
+        "rootDir": "./",
+        "outDir": "lib",
+        "declaration": true,
+        "declarationDir": "./lib",
+        "declarationMap": true,
+        "sourceMap": true,
+        "resolveJsonModule": true,
+        "types": ["jest", "node", "@types/jest", "@types/offscreencanvas"],
+        "typeRoots" : ["./src/define"],
+        "downlevelIteration": true,
+        "experimentalDecorators": true
+    }
+}