├── .gitattributes ├── .github └── workflows │ ├── build.yml │ ├── documentation.yml │ └── publish.yml ├── .gitignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── demo ├── build.sh ├── overview.dot └── overview.png ├── eslint.config.mjs ├── inject_cf_analytics.sh ├── package-lock.json ├── package.json ├── prepare └── main.py ├── rollup.config.mjs ├── src ├── TshetUinh.ts ├── data │ ├── 廣韻.spec.ts │ ├── 廣韻.ts │ └── 廣韻impl.ts ├── index.ts └── lib │ ├── utils.ts │ ├── 壓縮表示.spec.ts │ ├── 壓縮表示.ts │ ├── 常用表達式.ts │ ├── 拓展音韻屬性.ts │ ├── 資料.spec.ts │ ├── 資料.ts │ ├── 音韻地位.spec.ts │ ├── 音韻地位.ts │ └── 音韻屬性常量.ts ├── tsconfig.json └── tsconfig.test.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - gh-pages 7 | paths-ignore: 8 | - '*.md' 9 | - 'docs/**' 10 | pull_request: 11 | branches-ignore: 12 | - gh-pages 13 | paths-ignore: 14 | - '*.md' 15 | - 'docs/**' 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | node-version: [ '18', '20', '22' ] 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Setup Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: '3.12' 29 | - name: Use Node.js ${{ matrix.node-version }} 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | - name: Install dependencies 34 | run: npm ci 35 | - name: Prepare 36 | run: python prepare/main.py 37 | - name: Test 38 | run: npm test 39 | - name: Build documentation 40 | run: | 41 | npm run doc:html 42 | ./inject_cf_analytics.sh 43 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | if: ${{ !github.event.release.prerelease }} 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Setup Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.12' 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '22' 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Prepare 24 | run: python prepare/main.py 25 | - name: Build 26 | run: npm run build 27 | - name: Build documentation 28 | run: | 29 | npm run doc:html 30 | ./inject_cf_analytics.sh 31 | - name: Publish 32 | run: | 33 | # Create a temporary directory 34 | export temp_dir=`mktemp -d -p ~` 35 | 36 | ( 37 | # Preserve .git 38 | mv .git $temp_dir 39 | cd $temp_dir 40 | 41 | # Switch branch 42 | git fetch 43 | git checkout gh-pages 44 | ) 45 | 46 | ( 47 | # Go to docs directory 48 | cd docs 49 | 50 | # Set no Jekyll 51 | touch .nojekyll 52 | 53 | # Move .git 54 | mv $temp_dir/.git . 55 | 56 | # Set commit identity 57 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 58 | git config user.name "github-actions[bot]" 59 | 60 | # Publish 61 | git add . 62 | if [ -n "$(git status --porcelain)" ]; then 63 | git commit -m "Publish `TZ='Asia/Hong_Kong' date`" 64 | git push -f origin gh-pages 65 | fi 66 | ) 67 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Setup Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.12' 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '22' 20 | registry-url: https://registry.npmjs.org/ 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Prepare 24 | run: python prepare/main.py 25 | - name: Test 26 | run: npm test 27 | - name: Publish to npm 28 | shell: bash 29 | run: | 30 | if [[ "$IS_BETA" ]]; then 31 | npm publish --tag beta 32 | else 33 | npm publish 34 | fi 35 | env: 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN_TSHET_UINH }} 37 | IS_BETA: ${{ github.event.release.prerelease && '1' || '' }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .nyc_output/ 3 | .rollup.cache/ 4 | /build/ 5 | /node_modules/ 6 | /test/ 7 | /coverage/ 8 | *.log 9 | yarn.lock 10 | 11 | /index.js 12 | /index.js.map 13 | /index.d.ts 14 | 15 | /docs 16 | 17 | /prepare/data.csv 18 | /prepare/v2.csv 19 | 20 | /src/data/raw/ 21 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 140, 4 | "quoteProps": "consistent", 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "eamodio.gitlens", 5 | "streetsidesoftware.code-spell-checker", 6 | "esbenp.prettier-vscode", 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | // To debug, make sure a *.spec.ts file is active in the editor, then run a configuration 5 | { 6 | "type": "node", 7 | "request": "launch", 8 | "name": "Debug Active Spec", 9 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ava", 10 | "runtimeArgs": ["debug", "--break", "--serial", "${file}"], 11 | "port": 9229, 12 | "outputCapture": "std", 13 | "skipFiles": ["/**/*.js"], 14 | "preLaunchTask": "npm: build" 15 | // "smartStep": true 16 | }, 17 | { 18 | // Use this one if you're already running `yarn watch` 19 | "type": "node", 20 | "request": "launch", 21 | "name": "Debug Active Spec (no build)", 22 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ava", 23 | "runtimeArgs": ["debug", "--break", "--serial", "${file}"], 24 | "port": 9229, 25 | "outputCapture": "std", 26 | "skipFiles": ["/**/*.js"] 27 | // "smartStep": true 28 | }] 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Project NK2028 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TshetUinh.js 2 | 3 | [![](https://badge.fury.io/js/tshet-uinh.svg)](https://www.npmjs.com/package/tshet-uinh) [![](https://data.jsdelivr.com/v1/package/npm/tshet-uinh/badge)](https://www.jsdelivr.com/package/npm/tshet-uinh) [![](https://github.com/nk2028/tshet-uinh-js/workflows/Package/badge.svg)](https://github.com/nk2028/tshet-uinh-js/actions?query=workflow%3A%22Package%22) [![](https://api.codeclimate.com/v1/badges/fb728b8ee3531bd96e5a/maintainability)](https://codeclimate.com/github/nk2028/tshet-uinh-js/maintainability) 4 | 5 | A JavaScript library for the Qieyun (Tshet-uinh) phonological system 6 | 7 | ![library overview](https://raw.githubusercontent.com/nk2028/tshet-uinh-js/cb6dc80/demo/overview.png) 8 | 9 | ## Usage 10 | 11 | Browser: 12 | 13 | ```html 14 | 15 | ``` 16 | 17 | Node.js: 18 | 19 | ```sh 20 | $ npm install tshet-uinh 21 | ``` 22 | 23 | ```javascript 24 | > import TshetUinh from 'tshet-uinh'; 25 | ``` 26 | 27 | ## Documentation 28 | 29 | See [here](https://nk2028.shn.hk/tshet-uinh-js/). 30 | -------------------------------------------------------------------------------- /demo/build.sh: -------------------------------------------------------------------------------- 1 | dot -Tpng demo/overview.dot > demo/overview.png 2 | -------------------------------------------------------------------------------- /demo/overview.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | graph [fontname="Noto Sans CJK HK"] 3 | node [fontname="Noto Sans CJK HK"] 4 | edge [fontname="Noto Sans Mono CJK HK"] 5 | node [shape=plaintext] 6 | rankdir=LR 7 | nodesep=0.375 8 | 9 | 音韻地位 -> 各項音韻屬性 [label="母, 呼, 等, …; 描述, 表達式; 屬於, 判斷"] 10 | 條目 -> 詳細資料 [label="反切, 釋義, 來源"] 11 | 12 | { rank=same 音韻地位 條目 漢字 } 13 | 14 | { 15 | edge[constraint=false] 16 | 17 | 音韻地位 -> 條目 [xlabel="query音韻地位"] 18 | 條目 -> 音韻地位 [xlabel="音韻地位"] 19 | 20 | 漢字 -> 條目 [xlabel="query字頭"] 21 | 條目 -> 漢字 [xlabel="字頭"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-js/3d941e2b0538cbbb0ae3ac46083075322295e2c8/demo/overview.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { dirname } from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | import { FlatCompat } from '@eslint/eslintrc'; 7 | import eslint from '@eslint/js'; 8 | import prettierConfig from 'eslint-config-prettier'; 9 | import eslintCommentsPlugin from 'eslint-plugin-eslint-comments'; 10 | import * as importPlugin from 'eslint-plugin-import'; 11 | import tseslint from 'typescript-eslint'; 12 | 13 | const __dirname = dirname(fileURLToPath(import.meta.url)); 14 | 15 | const compat = new FlatCompat({ 16 | baseDirectory: __dirname, 17 | }); 18 | 19 | export default tseslint.config( 20 | { 21 | files: ['src/**/*.?(c|m)js', '*.?(c|m)js', 'src/**/*.ts'], 22 | ignores: ['index.js'], 23 | extends: [ 24 | eslint.configs.recommended, 25 | ...compat.extends('plugin:eslint-comments/recommended'), 26 | ...compat.extends('plugin:import/typescript'), 27 | ], 28 | plugins: { 29 | 'eslint-comments': eslintCommentsPlugin, 30 | 'import': importPlugin, 31 | }, 32 | rules: { 33 | 'eslint-comments/disable-enable-pair': ['error', { allowWholeFile: true }], 34 | 'eslint-comments/no-unused-disable': 'error', 35 | 36 | 'import/order': [ 37 | 'error', 38 | { 39 | 'newlines-between': 'always', 40 | 'alphabetize': { order: 'asc' }, 41 | }, 42 | ], 43 | 44 | 'sort-imports': [ 45 | 'error', 46 | { 47 | ignoreDeclarationSort: true, 48 | ignoreCase: true, 49 | }, 50 | ], 51 | }, 52 | }, 53 | { 54 | files: ['src/**/*.ts'], 55 | extends: [ 56 | //...tseslint.configs.recommended, 57 | //...tseslint.configs.recommendedTypeChecked, 58 | ...tseslint.configs.strictTypeChecked, 59 | ...tseslint.configs.stylisticTypeChecked, 60 | ], 61 | languageOptions: { 62 | parserOptions: { 63 | project: './tsconfig.test.json', 64 | tsconfigRootDir: __dirname, 65 | }, 66 | }, 67 | rules: { 68 | '@typescript-eslint/explicit-module-boundary-types': 'off', 69 | 70 | '@typescript-eslint/no-non-null-assertion': 'off', 71 | '@typescript-eslint/no-unnecessary-condition': 'off', 72 | '@typescript-eslint/restrict-template-expressions': [ 73 | 'error', 74 | { 75 | allowBoolean: true, 76 | allowNullish: true, 77 | allowNumber: true, 78 | }, 79 | ], 80 | 81 | '@typescript-eslint/no-unused-expressions': [ 82 | 'error', 83 | { 84 | allowShortCircuit: true, 85 | }, 86 | ], 87 | }, 88 | }, 89 | prettierConfig, 90 | ); 91 | -------------------------------------------------------------------------------- /inject_cf_analytics.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TARGET_DIR="./docs" 4 | SCRIPT="" 5 | 6 | find "$TARGET_DIR" -type f -name "*.html" | while read -r FILE; do 7 | sed -i "s||$SCRIPT|g" "$FILE" 8 | done 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tshet-uinh", 3 | "version": "0.15.1", 4 | "description": "A JavaScript library for the Qieyun phonological system", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "build": "run-p build:*", 9 | "build:rollup": "rollup -c", 10 | "build:types": "npm-dts generate", 11 | "build:test": "tsc -p tsconfig.test.json", 12 | "fix": "run-s fix:*", 13 | "fix:format": "prettier 'src/**/*.ts' --write", 14 | "fix:lint": "eslint 'src/**/*.ts' --fix", 15 | "test": "run-s build test:*", 16 | "test:lint": "eslint src", 17 | "test:format": "prettier src --list-different", 18 | "test:unit": "nyc --silent ava", 19 | "check-cli": "run-s test diff-integration-tests check-integration-tests", 20 | "check-integration-tests": "run-s check-integration-test:*", 21 | "diff-integration-tests": "mkdir -p diff && rm -rf diff/test && cp -r test diff/test && rm -rf diff/test/test-*/.git && cd diff && git init --quiet && git add -A && git commit --quiet --no-verify --allow-empty -m 'WIP' && echo '\\n\\nCommitted most recent integration test output in the \"diff\" directory. Review the changes with \"cd diff && git diff HEAD\" or your preferred git diff viewer.'", 22 | "watch:build-tests": "tsc -p tsconfig.test.json -w", 23 | "watch:test": "nyc --silent ava --watch", 24 | "cov": "run-s build test:unit cov:html cov:lcov && open-cli coverage/index.html", 25 | "cov:html": "nyc report --reporter=html", 26 | "cov:lcov": "nyc report --reporter=lcov", 27 | "cov:send": "run-s cov:lcov && codecov", 28 | "cov:check": "nyc report && nyc check-coverage --lines 100 --functions 100 --branches 100", 29 | "doc": "run-s doc:html && open-cli docs/index.html", 30 | "doc:html": "typedoc src/index.ts --out docs", 31 | "doc:json": "typedoc src/index.ts --json docs/typedoc.json", 32 | "reset-hard": "git clean -dfx && git reset --hard && npm i", 33 | "prepare-release": "run-s reset-hard test cov:check doc:html" 34 | }, 35 | "engines": { 36 | "node": "^18.18 || ^20.9 || ^21 || >=22" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/nk2028/tshet-uinh-js.git" 41 | }, 42 | "keywords": [ 43 | "Qieyun", 44 | "historical linguistics", 45 | "linguistics", 46 | "Middle Chinese" 47 | ], 48 | "author": "Project NK2028", 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/nk2028/tshet-uinh-js/issues" 52 | }, 53 | "homepage": "https://github.com/nk2028/tshet-uinh-js#readme", 54 | "devDependencies": { 55 | "@ava/typescript": "^5.0.0", 56 | "@eslint/eslintrc": "^3.1.0", 57 | "@eslint/js": "^9.9.1", 58 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 59 | "@rollup/plugin-typescript": "^11.1.6", 60 | "@types/eslint__js": "^8.42.3", 61 | "ava": "^6.1.3", 62 | "cz-conventional-changelog": "^3.0.1", 63 | "eslint": "^8.57.0", 64 | "eslint-config-prettier": "^9.1.0", 65 | "eslint-plugin-eslint-comments": "^3.2.0", 66 | "eslint-plugin-import": "^2.29.1", 67 | "npm-dts": "^1.3.13", 68 | "npm-run-all": "^4.1.5", 69 | "nyc": "^17.0.0", 70 | "open-cli": "^8.0.0", 71 | "prettier": "^3.3.3", 72 | "rollup": "^4.21.2", 73 | "ts-node": "^10.9.2", 74 | "tslib": "^2.7.0", 75 | "typedoc": "^0.26.6", 76 | "typescript": "^5.5.4", 77 | "typescript-eslint": "^8.3.0" 78 | }, 79 | "files": [ 80 | "index.js", 81 | "index.js.map", 82 | "index.d.ts", 83 | "LICENSE", 84 | "README.md" 85 | ], 86 | "ava": { 87 | "failFast": true, 88 | "timeout": "60s", 89 | "typescript": { 90 | "rewritePaths": { 91 | "src/": "build/test/" 92 | }, 93 | "compile": false 94 | }, 95 | "files": [ 96 | "!build/esnext/**" 97 | ] 98 | }, 99 | "config": { 100 | "commitizen": { 101 | "path": "cz-conventional-changelog" 102 | } 103 | }, 104 | "nyc": { 105 | "extends": "@istanbuljs/nyc-config-typescript", 106 | "exclude": [ 107 | "**/*.spec.js" 108 | ] 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /prepare/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import csv 4 | import hashlib 5 | import os 6 | import re 7 | import sys 8 | 9 | 編碼表 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$_' 10 | 11 | 所有母 = '幫滂並明端透定泥來知徹澄孃精清從心邪莊初崇生俟章昌常書船日見溪羣疑影曉匣云以' 12 | 所有呼 = '開合' 13 | 所有等 = '一二三四' 14 | 所有類 = 'ABC' 15 | 所有韻 = '東冬鍾江支脂之微魚虞模齊祭泰佳皆夬灰咍廢真臻文殷元魂痕寒刪山先仙蕭宵肴豪歌麻陽唐庚耕清青蒸登尤侯幽侵覃談鹽添咸銜嚴凡' 16 | 所有聲 = '平上去入' 17 | 18 | 韻序表 = '東*冬鍾江支脂之微魚虞模齊祭泰佳皆夬灰咍廢真臻文殷元魂痕寒刪山先仙蕭宵肴豪歌*麻*陽唐庚*耕清青蒸登尤侯幽侵覃談鹽添咸銜嚴凡' 19 | 20 | PATTERN_描述 = re.compile( 21 | f'([{所有母}])([{所有呼}])?([{所有等}])([{所有類}])?([{所有韻}])([{所有聲}])' 22 | ) 23 | 24 | 25 | def 編碼_from_描述(描述: str) -> str: 26 | 母, 呼, 等, 類, 韻, 聲 = PATTERN_描述.fullmatch(描述).groups() 27 | # 資料均為可信任來源,且均為完整描述,省略驗證與填充 28 | 29 | 母序 = 所有母.index(母) 30 | 韻序 = 韻序表.index(韻) 31 | if 韻 in tuple('東歌麻庚') and 等 not in tuple('一二'): 32 | 韻序 += 1 33 | 呼序 = 所有呼.index(呼) + 1 if 呼 else 0 34 | 類序 = 所有類.index(類) + 1 if 類 else 0 35 | 36 | 呼類聲序 = (呼序 << 4) | (類序 << 2) | 所有聲.index(聲) 37 | 38 | return 編碼表[母序] + 編碼表[韻序] + 編碼表[呼類聲序] 39 | 40 | 41 | def fetch_data( 42 | commit: str = '1f1c085', 43 | md5sum: str = '92d1e840e7b118bc6b541c5aa5c9db8c', 44 | ): 45 | if not os.path.exists('prepare/data.csv'): 46 | status = os.system( 47 | f'curl -LsSo prepare/data.csv https://raw.githubusercontent.com/nk2028/tshet-uinh-data/{commit}/%E9%9F%BB%E6%9B%B8/%E5%BB%A3%E9%9F%BB.csv' 48 | ) 49 | assert status == 0, f'Error: curl exited with status code {status}' 50 | # NOTE `file_digest` requires Python 3.11+ 51 | with open('prepare/data.csv', 'rb') as fin: 52 | digest = hashlib.file_digest(fin, 'md5') 53 | actual_checksum = digest.hexdigest() 54 | if md5sum == 'SKIP': 55 | print(f'MD5 checksum of data.csv (not checked): {actual_checksum}') 56 | else: 57 | md5sum = md5sum.lower() 58 | if md5sum != actual_checksum: 59 | print('Error: checksum failed for data.csv:') 60 | print(f' Expected: {md5sum}') 61 | print(f' Actual : {actual_checksum}') 62 | exit(2) 63 | 64 | 65 | # 偵錯用 66 | def list_地位編碼(): 67 | fetch_data() 68 | all_codes = {} 69 | with open('prepare/data.csv') as fin: 70 | for row in csv.DictReader(fin): 71 | 描述 = row['音韻地位'] 72 | if 描述 == '' or 描述 in all_codes: 73 | continue 74 | all_codes[描述] = 編碼_from_描述(描述) 75 | for 描述, 編碼 in sorted(all_codes.items(), key=lambda x: x[1]): 76 | print(編碼, 描述) 77 | 78 | 79 | def main(): 80 | fetch_data() 81 | 82 | 韻目原貌by原書小韻: dict[int, str] = {} 83 | 原書小韻音韻: dict[int, dict[str, tuple[str, str]]] = {} 84 | 原書小韻內容: dict[int, list[tuple[str, str, str]]] = {} 85 | with open('prepare/data.csv') as fin: 86 | next(fin) 87 | max原書小韻號: int = 0 88 | cur音韻: dict[str, tuple[str, str]] = None 89 | cur內容: list[tuple[str, str, str]] = None 90 | for row in csv.reader(fin): 91 | ( 92 | 小韻號, 93 | _, 94 | 韻目原貌, 95 | 音韻地位描述, 96 | 反切, 97 | 字頭, 98 | 釋義, 99 | 釋義補充, 100 | ) = row 101 | 102 | if 小韻號[-1].isalpha(): 103 | 原書小韻號, 小韻細分 = int(小韻號[:-1]), 小韻號[-1] 104 | else: 105 | 原書小韻號, 小韻細分 = int(小韻號), '' 106 | 107 | if 原書小韻號 != max原書小韻號: 108 | assert 原書小韻號 == max原書小韻號 + 1 109 | max原書小韻號 = 原書小韻號 110 | 韻目原貌by原書小韻[原書小韻號] = 韻目原貌 111 | 原書小韻音韻[原書小韻號] = cur音韻 = {} 112 | 原書小韻內容[原書小韻號] = cur內容 = [] 113 | 114 | assert 韻目原貌 == 韻目原貌by原書小韻[原書小韻號] 115 | 116 | 音韻編碼 = 編碼_from_描述(音韻地位描述) if 音韻地位描述 else '@@@' 117 | if 小韻細分 in cur音韻: 118 | assert cur音韻[小韻細分] == (音韻編碼, 反切) 119 | else: 120 | cur音韻[小韻細分] = (音韻編碼, 反切) 121 | 122 | cur內容.append((字頭, 小韻細分, 釋義 + (釋義補充 and f'({釋義補充})'))) 123 | 124 | for 原書小韻號, 各音韻信息 in 原書小韻音韻.items(): 125 | 各細分 = tuple(各音韻信息.keys()) 126 | if len(各細分) == 1: 127 | assert 各細分[0] == '' 128 | else: 129 | assert 1 < len(各細分) <= 26 130 | assert 各細分 == tuple(chr(ord('a') + i) for i in range(len(各細分))) 131 | 132 | os.makedirs('src/data/raw', exist_ok=True) 133 | with open('src/data/raw/廣韻.ts', 'w', newline='') as fout: 134 | print('export default `\\', file=fout) 135 | cur韻目 = None 136 | for 原書小韻號 in range(1, max原書小韻號 + 1): 137 | 韻目 = 韻目原貌by原書小韻[原書小韻號] 138 | if 韻目 != cur韻目: 139 | print(f'#{韻目}', file=fout) 140 | cur韻目 = 韻目 141 | print( 142 | ''.join( 143 | 音韻編碼 + (反切 or '@@') 144 | for 音韻編碼, 反切 in 原書小韻音韻[原書小韻號].values() 145 | ), 146 | '|'.join( 147 | 字頭 + 小韻細分 + 釋義 148 | for 字頭, 小韻細分, 釋義 in 原書小韻內容[原書小韻號] 149 | ), 150 | sep='', 151 | file=fout, 152 | ) 153 | print('` as string;', file=fout) 154 | 155 | 156 | if __name__ == '__main__': 157 | if len(sys.argv) == 2 and sys.argv[1] == 'test': 158 | list_地位編碼() 159 | else: 160 | main() 161 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import typescript from '@rollup/plugin-typescript'; 4 | 5 | export default [ 6 | { 7 | input: 'src/index.ts', 8 | output: { 9 | file: 'index.js', 10 | format: 'umd', 11 | sourcemap: true, 12 | name: 'TshetUinh', 13 | exports: 'named', 14 | }, 15 | plugins: [ 16 | typescript({ 17 | // NOTE Apparently needed with `"incremental": true` in tsconfig 18 | outputToFilesystem: false, 19 | }), 20 | ], 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /src/TshetUinh.ts: -------------------------------------------------------------------------------- 1 | export { 音韻地位 } from './lib/音韻地位'; 2 | export type { 部分音韻屬性, 判斷規則列表, 邊緣地位種類指定 } from './lib/音韻地位'; 3 | 4 | export * as 資料 from './lib/資料'; 5 | 6 | export * as 表達式 from './lib/常用表達式'; 7 | 8 | export * as 壓縮表示 from './lib/壓縮表示'; 9 | -------------------------------------------------------------------------------- /src/data/廣韻.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | 3 | import test from 'ava'; 4 | 5 | import * as 廣韻 from './廣韻'; 6 | 7 | test('檢索廣韻小韻', t => { 8 | const 小韻3708a = 廣韻.get小韻('3708a')!; 9 | const 小韻3708b = 廣韻.get小韻('3708b')!; 10 | t.is(小韻3708a.length, 15); 11 | t.is(小韻3708a[0].字頭, '憶'); 12 | t.is(小韻3708b.length, 2); 13 | t.is(小韻3708b[0].字頭, '抑'); 14 | 15 | const collect字頭 = (結果: 廣韻.廣韻條目[]) => 結果.map(x => x.字頭); 16 | 17 | const 原書小韻3708 = 廣韻.get原書小韻(3708)!; 18 | t.is(原書小韻3708.length, 17); 19 | t.deepEqual([...collect字頭(小韻3708a), ...collect字頭(小韻3708b)].sort(), collect字頭(原書小韻3708).sort()); 20 | 21 | const 小韻597 = 廣韻.get小韻('597')!; 22 | t.deepEqual(collect字頭(小韻597), ['𤜼']); 23 | t.is(小韻597[0].音韻地位, null); 24 | }); 25 | 26 | test('原書小韻總數', t => { 27 | t.is(廣韻.原書小韻總數, 3874); 28 | }); 29 | 30 | test('對照 iter原書小韻 與 iter條目', t => { 31 | const it1 = 廣韻.iter原書小韻(); 32 | const it2 = 廣韻.iter條目(); 33 | 34 | for (const 原書小韻 of it1) { 35 | for (const 條目1 of 原書小韻) { 36 | const next = it2.next(); 37 | t.falsy(next.done); 38 | const 條目2 = (next as IteratorYieldResult<廣韻.廣韻條目>).value; 39 | 40 | t.is(條目1.來源.小韻號, 條目2.來源.小韻號); 41 | t.is(條目1.來源.韻目, 條目2.來源.韻目); 42 | t.is(條目1.音韻地位?.描述, 條目2.音韻地位?.描述); 43 | t.is(條目1.反切, 條目2.反切); 44 | t.is(條目1.字頭, 條目2.字頭); 45 | t.is(條目1.釋義, 條目2.釋義); 46 | } 47 | } 48 | }); 49 | 50 | test('對照原資料檔與 iter條目', t => { 51 | const 條目iter = 廣韻.iter條目(); 52 | for (const line of readFileSync('prepare/data.csv', { encoding: 'utf8' }).trimEnd().split('\n').slice(1)) { 53 | const [小韻號, , 韻目原貌, 地位描述, 反切, 字頭, 釋義, 釋義補充] = line.split(','); 54 | 55 | const next = 條目iter.next(); 56 | t.falsy(next.done); 57 | const 條目 = (next as IteratorYieldResult<廣韻.廣韻條目>).value; 58 | t.is(條目.來源.小韻號, 小韻號); 59 | t.is(條目.來源.韻目, 韻目原貌); 60 | t.is(條目.音韻地位?.描述 ?? '', 地位描述); 61 | t.is(條目.反切, 反切 || null); 62 | t.is(條目.字頭, 字頭); 63 | t.is(條目.釋義, 釋義 + (釋義補充 && `(${釋義補充})`)); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /src/data/廣韻.ts: -------------------------------------------------------------------------------- 1 | import { decode音韻編碼 } from '../lib/壓縮表示'; 2 | import { 音韻地位 } from '../lib/音韻地位'; 3 | 4 | import * as impl from './廣韻impl'; 5 | 6 | export interface 廣韻條目 { 7 | 字頭: string; 8 | /** 音韻地位。若條目為訛字並導致該小韻音韻地位無效,則為 `null` */ 9 | 音韻地位: 音韻地位 | null; 10 | /** 反切。若未用反切注音(如「音某字某聲」)則為 `null` */ 11 | 反切: string | null; 12 | 釋義: string; 13 | 來源: 廣韻來源; 14 | } 15 | export interface 廣韻來源 { 16 | 文獻: '廣韻'; 17 | /** 18 | * 小韻號,由 1 至 3874。 19 | * 20 | * 部分小韻含多個音韻地位,會依音韻地位拆分,並有細分號(後綴 -a、-b 等),故為字串格式。 21 | * @see {@link get小韻} 22 | */ 23 | 小韻號: string; 24 | /** 原書韻目,與音韻地位不一定對應 */ 25 | 韻目: string; 26 | } 27 | 28 | /** 按原書順序遍歷全部廣韻條目。 */ 29 | export function* iter條目(): IterableIterator<廣韻條目> { 30 | for (const 原書小韻 of iter原書小韻()) { 31 | yield* 原書小韻; 32 | } 33 | } 34 | 35 | /** 36 | * 遍歷全部小韻號。 37 | * 38 | * 細分小韻(見 {@link get小韻})拆分為不同小韻,有各自的小韻號。 39 | */ 40 | export function iter小韻號(): IterableIterator { 41 | return impl.by小韻.keys(); 42 | } 43 | 44 | /** 45 | * 依小韻號獲取條目。 46 | * 47 | * 部分小韻含多個音韻地位,會依音韻地位拆分,並有細分號(後綴 -a、-b 等),故為字串格式。 48 | * 49 | * @returns 該小韻所有條目。若小韻號不存在,回傳 `undefined`。 50 | * @example 51 | * ```typescript 52 | * > TshetUinh.資料.廣韻.get小韻('3708b'); 53 | * [ 54 | * { 55 | * 字頭: '抑', 56 | * 音韻地位: 音韻地位<影開三B蒸入>, 57 | * 反切: '於力', 58 | * 釋義: '按也說文作𢑏从反印', 59 | * 來源: { 文獻: '廣韻', '小韻號': '3708b', 韻目: '職' }, 60 | * }, 61 | * { 62 | * 字頭: '𡊁', 63 | * 音韻地位: 音韻地位<影開三B蒸入>, 64 | * 反切: '於力', 65 | * 釋義: '地名', 66 | * 來源: { 文獻: '廣韻', 小韻號: '3708b', 韻目: '職' }, 67 | * }, 68 | * ] 69 | * ``` 70 | */ 71 | export function get小韻(小韻號: string): 廣韻條目[] | undefined { 72 | return impl.by小韻.get(小韻號)?.map(條目from內部條目); 73 | } 74 | 75 | /** 76 | * 遍歷全部小韻(細分小韻均拆分)。即對資料中全部小韻執行 {@link get小韻}。 77 | */ 78 | export function* iter小韻(): IterableIterator<廣韻條目[]> { 79 | for (const 小韻號 of iter小韻號()) { 80 | yield get小韻(小韻號)!; 81 | } 82 | } 83 | 84 | /** 原書小韻總數。細分小韻(含多個音韻地位的小韻)不拆分,計為一個小韻。 */ 85 | export const 原書小韻總數 = impl.by原書小韻.size; 86 | 87 | /** 88 | * 依原書小韻號獲取條目。 89 | * 90 | * 細分小韻(含多個音韻地位的小韻)不拆分,視為同一小韻。 91 | * 92 | * @param 原書小韻號 數字,應在 1 至 {@link 原書小韻總數}(含)之間。 93 | * @returns 該原書小韻所有條目 94 | */ 95 | export function get原書小韻(原書小韻號: number): 廣韻條目[] | undefined { 96 | return impl.by原書小韻.get(原書小韻號)?.map(條目from內部條目); 97 | } 98 | 99 | /** 100 | * 遍歷全部原書小韻(細分小韻不拆分)。即對資料中全部原書小韻執行 {@link get原書小韻}。 101 | */ 102 | export function* iter原書小韻(): IterableIterator<廣韻條目[]> { 103 | for (let i = 1; i <= 原書小韻總數; i++) { 104 | yield get原書小韻(i)!; 105 | } 106 | } 107 | 108 | function 條目from內部條目(內部條目: impl.內部廣韻條目): 廣韻條目 { 109 | const { 字頭, 音韻編碼, 小韻號, 韻目原貌, ...rest } = 內部條目; 110 | return { 111 | 字頭, 112 | 音韻地位: 音韻編碼 === null ? null : decode音韻編碼(音韻編碼), 113 | ...rest, 114 | 來源: { 文獻: '廣韻' as const, 小韻號, 韻目: 韻目原貌 }, 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /src/data/廣韻impl.ts: -------------------------------------------------------------------------------- 1 | import { insertInto } from '../lib/utils'; 2 | 3 | import raw資料 from './raw/廣韻'; 4 | 5 | export interface 內部廣韻條目 { 6 | 字頭: string; 7 | 音韻編碼: string | null; 8 | 反切: string | null; 9 | 釋義: string; 10 | 小韻號: string; 11 | 韻目原貌: string; 12 | } 13 | 14 | export const by原書小韻 = new Map(); 15 | export const by小韻 = new Map(); 16 | 17 | (function 解析資料() { 18 | let 原書小韻號 = 0; 19 | let 韻目原貌 = ''; 20 | let pos = 0; 21 | for (;;) { 22 | const posLF = raw資料.indexOf('\n', pos); 23 | if (posLF === -1) { 24 | break; 25 | } 26 | const line = raw資料.slice(pos, posLF + 1); 27 | pos = posLF + 1; 28 | 29 | if (line.startsWith('#')) { 30 | 韻目原貌 = line.slice(1, -1); 31 | continue; 32 | } 33 | 原書小韻號 += 1; 34 | 35 | const [, 音韻, 內容] = /^((?:[\w$@]{3}..)+)(.*\n)$/u.exec(line)!; 36 | const 各地位反切: [string | null, string | null][] = []; 37 | for (const [, 編碼str, 反切str] of 音韻.matchAll(/(...)(..)/gu)) { 38 | const 編碼 = 編碼str === '@@@' ? null : 編碼str; 39 | const 反切 = 反切str === '@@' ? null : 反切str; 40 | 各地位反切.push([編碼, 反切]); 41 | } 42 | for (const [, 字頭, 細分, 釋義] of 內容.matchAll(/(.)([a-z]?)(.*?)[|\n]/gu)) { 43 | const 小韻號 = String(原書小韻號) + 細分; 44 | const 細分index = (細分 || 'a').charCodeAt(0) - 'a'.charCodeAt(0); 45 | const [音韻編碼, 反切] = 各地位反切[細分index]; 46 | const 條目 = { 字頭, 音韻編碼, 反切, 釋義, 小韻號, 韻目原貌 }; 47 | insertInto(by原書小韻, 原書小韻號, 條目); 48 | insertInto(by小韻, 小韻號, 條目); 49 | } 50 | } 51 | })(); 52 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TshetUinh'; 2 | 3 | import * as TshetUinh from './TshetUinh'; 4 | export default TshetUinh; 5 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function assert(condition: unknown, errorMessage: string | (() => string)): asserts condition { 2 | if (!condition) { 3 | throw new Error(typeof errorMessage === 'function' ? errorMessage() : errorMessage); 4 | } 5 | } 6 | 7 | // NOTE This is for ensuring *invariance*(-ish) on the type of the map of `insertInto`. 8 | // This way, the type of `map` (`T`) is inferred first, then the other two arguments will be checked against it, rather than the types of 9 | // `key` and `value` dictating what the map should be like (because TypeScript sees `map` as *covariant* by default, which is not suitable 10 | // for mutable operations like insertion). 11 | export type KeyOfMap = T extends Map ? K : never; 12 | export type ValueOfMap = T extends Map ? V : never; 13 | export type ArrayElement = T extends (infer U)[] ? U : never; 14 | 15 | export function insertInto = Map>(map: T, key: KeyOfMap, value: ArrayElement>) { 16 | if (!map.has(key)) { 17 | map.set(key, [value]); 18 | } else { 19 | map.get(key)!.push(value); 20 | } 21 | } 22 | 23 | export function prependValuesInto = Map>(map: T, key: KeyOfMap, values: ValueOfMap) { 24 | if (!map.has(key)) { 25 | map.set(key, [...values]); 26 | } else { 27 | map.set(key, [...values, ...map.get(key)!]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/壓縮表示.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { decode音韻編碼, encode音韻編碼 } from './壓縮表示'; 4 | import { iter音韻地位 } from './資料'; 5 | import { 音韻地位 } from './音韻地位'; 6 | 7 | test('測試音韻編碼', t => { 8 | t.is(encode音韻編碼(音韻地位.from描述('幫三C凡入')), 'A9P'); 9 | t.is(encode音韻編碼(音韻地位.from描述('羣開三A支平')), 'fFU'); 10 | 11 | t.is(decode音韻編碼('A9P').描述, '幫三C凡入'); 12 | t.is(decode音韻編碼('fFU').描述, '羣開三A支平'); 13 | }); 14 | 15 | test('測試資料內全部音韻地位與編碼雙向轉換', t => { 16 | for (const 當前音韻地位 of iter音韻地位()) { 17 | const encoded = encode音韻編碼(當前音韻地位); 18 | const decoded = decode音韻編碼(encoded); 19 | t.true(decoded.等於(當前音韻地位), `${當前音韻地位.描述} -> ${encoded} -> ${decoded.描述}`); 20 | } 21 | }); 22 | 23 | test('測試不合法編碼', t => { 24 | t.throws(() => decode音韻編碼('A'), { message: 'Invalid 編碼: "A"' }); 25 | t.throws(() => decode音韻編碼('@@@'), { message: 'Invalid character in 編碼: "@"' }); 26 | t.throws(() => decode音韻編碼('mAA'), { message: 'Invalid 母序號: 38' }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/lib/壓縮表示.ts: -------------------------------------------------------------------------------- 1 | import { assert } from './utils'; 2 | import { _UNCHECKED, 音韻地位 } from './音韻地位'; 3 | import { 所有, 等韻搭配 } from './音韻屬性常量'; 4 | 5 | const 編碼表 = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$_'] as const; 6 | const 韻序表 = [ 7 | ...'東*冬鍾江支脂之微魚虞模齊祭泰佳皆夬灰咍廢真臻文殷元魂痕寒刪山先仙蕭宵肴豪歌*麻*陽唐庚*耕清青蒸登尤侯幽侵覃談鹽添咸銜嚴凡', 8 | ] as const; 9 | 10 | /** 11 | * 將音韻地位編碼為壓縮格式串。音韻編碼與音韻地位之間存在一一映射關係。 12 | * @param 地位 待編碼的音韻地位 13 | * @returns 音韻地位對應的編碼 14 | * @example 15 | * ```typescript 16 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 17 | * > TshetUinh.壓縮表示.encode音韻編碼(音韻地位); 18 | * 'A9P' 19 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 20 | * > TshetUinh.壓縮表示.encode音韻編碼(音韻地位); 21 | * 'fFU' 22 | * ``` 23 | */ 24 | export function encode音韻編碼(地位: 音韻地位): string { 25 | const { 母, 呼, 等, 類, 韻, 聲 } = 地位; 26 | const 母序 = 所有.母.indexOf(母); 27 | const 韻序 = 韻序表.indexOf(韻) + +([...'東歌麻庚'].includes(韻) && !['一', '二'].includes(等)); 28 | 29 | // NOTE the value `-1` is expected when the argument is `null` 30 | const 呼序 = 所有.呼.indexOf(呼!) + 1; 31 | const 類序 = 所有.類.indexOf(類!) + 1; 32 | 33 | const 呼類聲序 = (呼序 << 4) | (類序 << 2) | 所有.聲.indexOf(聲); 34 | 35 | return 編碼表[母序] + 編碼表[韻序] + 編碼表[呼類聲序]; 36 | } 37 | 38 | /** 39 | * 將音韻編碼解碼回音韻地位。 40 | * @param 編碼 音韻地位的編碼 41 | * @returns 給定的音韻編碼對應的音韻地位 42 | * @example 43 | * ```typescript 44 | * > TshetUinh.壓縮表示.decode音韻編碼('A9P'); 45 | * 音韻地位<幫三C凡入> 46 | * > TshetUinh.壓縮表示.decode音韻編碼('fFU'); 47 | * 音韻地位<羣開三A支平> 48 | * ``` 49 | */ 50 | export function decode音韻編碼(編碼: string): 音韻地位 { 51 | assert(編碼.length === 3, () => `Invalid 編碼: ${JSON.stringify(編碼)}`); 52 | 53 | const [母序, 韻序, 呼類聲序] = [...編碼].map(ch => { 54 | const index = 編碼表.indexOf(ch); 55 | assert(index !== -1, () => `Invalid character in 編碼: ${JSON.stringify(ch)}`); 56 | return index; 57 | }); 58 | assert(母序 < 所有.母.length, () => `Invalid 母序號: ${母序}`); 59 | const 母 = 所有.母[母序]; 60 | 61 | assert(韻序 < 韻序表.length, () => `Invalid 韻序號: ${韻序}`); 62 | let 韻 = 韻序表[韻序]; 63 | if (韻 === '*') { 64 | 韻 = 韻序表[韻序 - 1]; 65 | } 66 | let 等: string; 67 | for (const [韻等, 各韻] of Object.entries(等韻搭配)) { 68 | if (各韻.includes(韻)) { 69 | 等 = 韻等[+(韻序表[韻序] === '*')]; 70 | if (等 === '三' && [...'端透定泥'].includes(母)) { 71 | 等 = '四'; 72 | } 73 | break; 74 | } 75 | } 76 | 77 | const 呼序 = 呼類聲序 >> 4; 78 | assert(呼序 <= 所有.呼.length, () => `Invalid 呼序號: ${呼序}`); 79 | const 呼 = 呼序 ? 所有.呼[呼序 - 1] : null; 80 | 81 | const 類序 = (呼類聲序 >> 2) & 0b11; 82 | assert(類序 <= 所有.類.length, () => `Invalid 類序號: ${類序}`); 83 | const 類 = 類序 ? 所有.類[類序 - 1] : null; 84 | 85 | const 聲序 = 呼類聲序 & 0b11; 86 | const 聲 = 所有.聲[聲序]; 87 | 88 | // NOTE type assertion safe because the constructor checks it 89 | return new 音韻地位(母, 呼, 等!, 類, 韻, 聲, _UNCHECKED); 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/常用表達式.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 預定義的常用表達式,可用於 `音韻地位.屬於`。 3 | * 4 | * @example 5 | * ```typescript 6 | * > const { 分開合韻, 合口韻 } = TshetUinh.表達式; 7 | * > const 地位 = TshetUinh.音韻地位.from描述('羣合三C文平'); 8 | * > 地位.屬於`${分開合韻} 非 ${開合中立韻}` 9 | * true 10 | * ``` 11 | * 12 | * @module 表達式 13 | */ 14 | 15 | import { 呼韻搭配, 等韻搭配 } from './音韻屬性常量'; 16 | 17 | /** 一等韻 */ 18 | export const 一等韻 = 等韻搭配.一.join('') + '韻'; 19 | /** 二等韻 */ 20 | export const 二等韻 = 等韻搭配.二.join('') + '韻'; 21 | /** 三等韻(注意:拼端組時為四等) */ 22 | export const 三等韻 = 等韻搭配.三.join('') + '韻'; 23 | /** 四等韻 */ 24 | export const 四等韻 = 等韻搭配.四.join('') + '韻'; 25 | /** 一三等韻 */ 26 | export const 一三等韻 = 等韻搭配.一三.join('') + '韻'; 27 | /** 二三等韻(注意:拼端組時為二四等) */ 28 | export const 二三等韻 = 等韻搭配.二三.join('') + '韻'; 29 | 30 | /** 31 | * 韻內分開合口的韻 32 | */ 33 | export const 分開合韻 = 呼韻搭配.開合.join('') + '韻'; 34 | /** 35 | * 僅為開口的韻(含之、魚韻及效、深、咸攝諸韻) 36 | */ 37 | export const 開口韻 = 呼韻搭配.開.join('') + '韻'; 38 | /** 39 | * 僅為合口的韻 40 | */ 41 | export const 合口韻 = 呼韻搭配.合.join('') + '韻'; 42 | /** 43 | * 開合中立韻(東冬鍾江模尤侯) 44 | */ 45 | export const 開合中立韻 = 呼韻搭配.中立.join('') + '韻'; 46 | -------------------------------------------------------------------------------- /src/lib/拓展音韻屬性.ts: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | export const 母到清濁: Record = { 3 | 幫: '全清', 4 | 端: '全清', 知: '全清', 5 | 精: '全清', 心: '全清', 莊: '全清', 生: '全清', 章: '全清', 書: '全清', 6 | 見: '全清', 影: '全清', 曉: '全清', 7 | 滂: '次清', 8 | 透: '次清', 徹: '次清', 9 | 清: '次清', 初: '次清', 昌: '次清', 10 | 溪: '次清', 11 | 並: '全濁', 12 | 定: '全濁', 澄: '全濁', 13 | 從: '全濁', 邪: '全濁', 崇: '全濁', 俟: '全濁', 常: '全濁', 船: '全濁', 14 | 羣: '全濁', 匣: '全濁', 15 | 明: '次濁', 16 | 泥: '次濁', 孃: '次濁', 來: '次濁', 日: '次濁', 17 | 疑: '次濁', 云: '次濁', 以: '次濁', 18 | }; 19 | 20 | // prettier-ignore 21 | export const 母到組: Record = { 22 | 幫: '幫', 滂: '幫', 並: '幫', 明: '幫', 23 | 端: '端', 透: '端', 定: '端', 泥: '端', 24 | 知: '知', 徹: '知', 澄: '知', 孃: '知', 25 | 精: '精', 清: '精', 從: '精', 心: '精', 邪: '精', 26 | 莊: '莊', 初: '莊', 崇: '莊', 生: '莊', 俟: '莊', 27 | 章: '章', 昌: '章', 船: '章', 書: '章', 常: '章', 28 | 見: '見', 溪: '見', 羣: '見', 疑: '見', 29 | 影: '影', 曉: '影', 匣: '影', 云: '影', 30 | 來: null, 日: null, 以: null, 31 | }; 32 | 33 | // prettier-ignore 34 | export const 母到音: Record = { 35 | 幫: '脣', 滂: '脣', 並: '脣', 明: '脣', 36 | 端: '舌', 透: '舌', 定: '舌', 泥: '舌', 37 | 知: '舌', 徹: '舌', 澄: '舌', 孃: '舌', 38 | 來: '舌', 39 | 精: '齒', 清: '齒', 從: '齒', 心: '齒', 邪: '齒', 40 | 莊: '齒', 初: '齒', 崇: '齒', 生: '齒', 俟: '齒', 41 | 章: '齒', 昌: '齒', 常: '齒', 書: '齒', 船: '齒', 42 | 日: '齒', 43 | 見: '牙', 溪: '牙', 羣: '牙', 疑: '牙', 44 | 影: '喉', 曉: '喉', 匣: '喉', 云: '喉', 45 | 以: '喉', 46 | }; 47 | 48 | // prettier-ignore 49 | export const 韻到攝: Record = { 50 | 東: '通', 冬: '通', 鍾: '通', 51 | 江: '江', 52 | 支: '止', 脂: '止', 之: '止', 微: '止', 53 | 魚: '遇', 虞: '遇', 模: '遇', 54 | 齊: '蟹', 佳: '蟹', 皆: '蟹', 灰: '蟹', 咍: '蟹', 祭: '蟹', 泰: '蟹', 夬: '蟹', 廢: '蟹', 55 | 真: '臻', 諄: '臻', 臻: '臻', 文: '臻', 殷: '臻', 魂: '臻', 痕: '臻', 56 | 元: '山', 寒: '山', 桓: '山', 刪: '山', 山: '山', 先: '山', 仙: '山', 57 | 蕭: '效', 宵: '效', 肴: '效', 豪: '效', 58 | 歌: '果', 戈: '果', 59 | 麻: '假', 60 | 唐: '宕', 陽: '宕', 61 | 庚: '梗', 耕: '梗', 清: '梗', 青: '梗', 62 | 登: '曾', 蒸: '曾', 63 | 侯: '流', 尤: '流', 幽: '流', 64 | 侵: '深', 65 | 覃: '咸', 談: '咸', 鹽: '咸', 添: '咸', 咸: '咸', 銜: '咸', 嚴: '咸', 凡: '咸', 66 | }; 67 | -------------------------------------------------------------------------------- /src/lib/資料.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | 3 | import test from 'ava'; 4 | 5 | import { query字頭, query音韻地位 } from './資料'; 6 | import { 音韻地位 } from './音韻地位'; 7 | 8 | test('查「東」字的反切', t => { 9 | const 字頭 = '東'; 10 | const res = query字頭(字頭); 11 | t.is(res.length, 1); 12 | t.is(res[0].反切, '德紅'); 13 | }); 14 | 15 | test('查「拯」字的反切,「拯」字無反切,值為 null', t => { 16 | const 字頭 = '拯'; 17 | const res = query字頭(字頭); 18 | t.is(res.length, 1); 19 | t.is(res[0].反切, null); 20 | }); 21 | 22 | test('查同地位不同反切', t => { 23 | const 地位 = 音韻地位.from描述('見開四添去'); 24 | const 條目 = query音韻地位(地位); 25 | t.is(條目.find(({ 字頭 }) => 字頭 === '趝')!.反切, '紀念'); 26 | t.is(條目.find(({ 字頭 }) => 字頭 === '兼')!.反切, '古念'); 27 | }); 28 | 29 | test('查音韻地位「見合一歌平」,含「戈」、「過」等字', t => { 30 | const 當前音韻地位 = 音韻地位.from描述('見合一歌平'); // 注意:戈韻不獨立,屬歌韻 31 | t.true(query音韻地位(當前音韻地位).length > 0); 32 | }); 33 | 34 | test('查音韻地位「從合三歌平」,有音無字', t => { 35 | const 當前音韻地位 = 音韻地位.from描述('從合三歌平'); 36 | t.is(query音韻地位(當前音韻地位).length, 0); 37 | }); 38 | 39 | test('查詢「之」字', t => { 40 | const res = query字頭('之'); 41 | t.is(res.length, 1); 42 | t.is(res[0].音韻地位.描述, '章開三之平'); 43 | t.is(res[0].釋義, '適也往也閒也亦姓出姓苑止而切四'); 44 | }); 45 | 46 | test('查詢「過」字。「過」字有兩讀', t => { 47 | const res = query字頭('過'); 48 | t.is(res.length, 2); 49 | }); 50 | 51 | test('查詢資料不包含的字,沒有讀音', t => { 52 | const res = query字頭('韓'); // 《廣韻》字頭作「𩏑」,同時釋義注「亦作韓」 53 | t.is(res.length, 0); 54 | }); 55 | 56 | test('查詢來源', t => { 57 | t.like( 58 | query字頭('茝').find(({ 音韻地位 }) => 音韻地位.屬於('廢韻')), 59 | { 來源: { 文獻: '廣韻', 韻目: '海' } }, 60 | ); 61 | t.like( 62 | query字頭('韻').find(({ 音韻地位 }) => 音韻地位.屬於('B類')), 63 | { 來源: { 文獻: '王三', 韻目: '震' } }, 64 | ); 65 | t.like( 66 | query字頭('忘').find(({ 音韻地位 }) => 音韻地位.屬於('去聲')), 67 | { 來源: { 文獻: '廣韻', 韻目: '漾' } }, 68 | ); 69 | t.like( 70 | query字頭('忘').find(({ 音韻地位 }) => 音韻地位.屬於('平聲')), 71 | { 來源: { 文獻: '王三', 韻目: '陽' } }, 72 | ); 73 | }); 74 | 75 | test('根據原資料檔查詢所有字頭', t => { 76 | for (const line of readFileSync('prepare/data.csv', { encoding: 'utf8' }).trimEnd().split('\n').slice(1)) { 77 | const [, , 韻目原貌, 地位描述1, 原反切1, 字頭1, 原釋義1, 釋義補充1] = line.split(','); 78 | if (!地位描述1) { 79 | continue; 80 | } 81 | const 反切1 = 原反切1 || null; 82 | const 釋義1 = 原釋義1 + (釋義補充1 && `(${釋義補充1})`); 83 | const 音韻地位1 = 音韻地位.from描述(地位描述1); 84 | 85 | t.true( 86 | query字頭(字頭1).some(({ 字頭: 字頭2, 音韻地位: 音韻地位2, 反切: 反切2, 釋義: 釋義2, 來源 }) => { 87 | return ( 88 | 字頭1 === 字頭2 && 89 | 音韻地位1.等於(音韻地位2) && 90 | 反切1 === 反切2 && 91 | 釋義1 === 釋義2 && 92 | 來源?.文獻 === '廣韻' && 93 | 來源.韻目 === 韻目原貌 94 | ); 95 | }), 96 | line, 97 | ); 98 | } 99 | }); 100 | -------------------------------------------------------------------------------- /src/lib/資料.ts: -------------------------------------------------------------------------------- 1 | import type { 廣韻來源 } from '../data/廣韻'; 2 | import * as 廣韻impl from '../data/廣韻impl'; 3 | 4 | import { insertInto, prependValuesInto } from './utils'; 5 | import { decode音韻編碼, encode音韻編碼 } from './壓縮表示'; 6 | import { 音韻地位 } from './音韻地位'; 7 | 8 | export * as 廣韻 from '../data/廣韻'; 9 | export type { 廣韻來源 } from '../data/廣韻'; 10 | 11 | type 內部檢索結果 = Readonly<{ 字頭: string; 編碼: string; 反切: string | null; 釋義: string; 來源: 來源類型 | null }>; 12 | 13 | export interface 檢索結果 { 14 | 字頭: string; 15 | 音韻地位: 音韻地位; 16 | /** 反切,若未用反切注音(如「音某字某聲」)則為 `null` */ 17 | 反切: string | null; 18 | 釋義: string; 19 | 來源: 來源類型 | null; 20 | } 21 | export type 來源類型 = 廣韻來源 | 王三來源; 22 | export interface 王三來源 { 23 | 文獻: '王三'; 24 | 小韻號: string; 25 | 韻目: string; 26 | } 27 | 28 | const m字頭檢索 = new Map(); 29 | const m音韻編碼檢索 = new Map(); 30 | 31 | (function 廣韻索引() { 32 | for (const 原書小韻 of 廣韻impl.by原書小韻.values()) { 33 | for (const 廣韻條目 of 原書小韻) { 34 | if (廣韻條目.音韻編碼 === null) { 35 | continue; 36 | } 37 | const { 字頭, 音韻編碼: 編碼, 小韻號, 韻目原貌, ...rest } = 廣韻條目; 38 | const 條目 = { 字頭, 編碼, ...rest, 來源: { 文獻: '廣韻' as const, 小韻號, 韻目: 韻目原貌 } }; 39 | insertInto(m字頭檢索, 字頭, 條目); 40 | insertInto(m音韻編碼檢索, 編碼, 條目); 41 | } 42 | } 43 | })(); 44 | 45 | (function 早期廣韻外字() { 46 | const by字頭 = new Map(); 47 | for (const [字頭, 描述, 反切, 釋義, 小韻號, 韻目] of [ 48 | ['忘', '明三C陽平', '武方', '遺又武放不記曰忘', '797', '陽'], 49 | ['韻', '云合三B真去', '爲捃', '為捃反音和一', '2420', '震'], 50 | ] as const) { 51 | const 編碼 = encode音韻編碼(音韻地位.from描述(描述)); 52 | const record = { 字頭, 編碼, 反切, 釋義, 來源: { 文獻: '王三' as const, 小韻號, 韻目 } }; 53 | insertInto(by字頭, 字頭, record); 54 | insertInto(m音韻編碼檢索, 編碼, record); 55 | } 56 | 57 | for (const [字頭, 各條目] of by字頭.entries()) { 58 | prependValuesInto(m字頭檢索, 字頭, 各條目); 59 | } 60 | })(); 61 | 62 | function 結果from內部結果(內部結果: 內部檢索結果): 檢索結果 { 63 | const { 字頭, 編碼, 來源, ...rest } = 內部結果; 64 | return { 65 | 字頭, 66 | 音韻地位: decode音韻編碼(編碼), 67 | ...rest, 68 | 來源: 來源 ? { ...來源 } : null, 69 | }; 70 | } 71 | 72 | /** 73 | * 遍歷內置資料中全部有字之音韻地位。 74 | * @returns 迭代器,所有至少對應一個字頭的音韻地位 75 | */ 76 | export function* iter音韻地位(): IterableIterator<音韻地位> { 77 | for (const 音韻編碼 of m音韻編碼檢索.keys()) { 78 | yield decode音韻編碼(音韻編碼); 79 | } 80 | } 81 | 82 | /** 83 | * 由字頭查出相應的音韻地位、反切、解釋。 84 | * @param 字頭 待查找的漢字 85 | * @returns 陣列,每一項包含音韻地位和解釋 86 | * 87 | * 若查不到該字,則回傳空陣列。 88 | * @example 89 | * ```typescript 90 | * > TshetUinh.資料.query字頭('結'); 91 | * [ { 92 | * 字頭: '結', 93 | * 音韻地位: 音韻地位<見開四先入>, 94 | * 反切: '古屑', 95 | * 釋義: '締也古屑切十五', 96 | * 來源: { 文獻: '廣韻', 小韻號: '3469', 韻目: '屑' }, 97 | * } ] 98 | * > TshetUinh.資料.query字頭('冷'); 99 | * [ 100 | * { 101 | * 字頭: '冷', 102 | * 音韻地位: 音韻地位<來開四青平>, 103 | * 反切: '郎丁', 104 | * 釋義: '冷凙吳人云冰凌又力頂切', 105 | * 來源: { 文獻: '廣韻', 小韻號: '939', 韻目: '青' }, 106 | * }, 107 | * { 108 | * 字頭: '冷', 109 | * 音韻地位: 音韻地位<來開二庚上>, 110 | * 反切: '魯打', 111 | * 釋義: '寒也魯打切又魯頂切一', 112 | * 來源: { 文獻: '廣韻', 小韻號: '1872', 韻目: '梗' }, 113 | * }, 114 | * { 115 | * 字頭: '冷', 116 | * 音韻地位: 音韻地位<來開四青上>, 117 | * 反切: '力鼎', 118 | * 釋義: '寒也又姓前趙錄有徐州刺史冷道字安義又盧打切', 119 | * 來源: { 文獻: '廣韻', 小韻號: '1915', 韻目: '迥' }, 120 | * }, 121 | * ] 122 | * ``` 123 | */ 124 | export function query字頭(字頭: string): 檢索結果[] { 125 | return m字頭檢索.get(字頭)?.map(結果from內部結果) ?? []; 126 | } 127 | 128 | /** 129 | * 查詢音韻地位對應的字頭、反切、解釋。 130 | * 131 | * @param 地位 待查詢的音韻地位 132 | * 133 | * @returns 陣列,每一項包含音韻地位和解釋 134 | * 135 | * 若音韻地位有音無字,則值為空陣列。 136 | * @example 137 | * ```typescript 138 | * > 地位 = TshetUinh.音韻地位.from描述('影開二銜去'); 139 | * > TshetUinh.資料.query音韻地位(地位); 140 | * [ { 141 | * 字頭: '𪒠', 142 | * 音韻地位: 音韻地位<影開二銜去>, 143 | * 反切: null, 144 | * 解釋: '叫呼仿佛𪒠然自得音黯去聲一', 145 | * 來源: { 文獻: '廣韻', 小韻號: '3177', 韻目: '鑑' }, 146 | * } ] 147 | * ``` 148 | */ 149 | export function query音韻地位(地位: 音韻地位): 檢索結果[] { 150 | return m音韻編碼檢索.get(encode音韻編碼(地位))?.map(結果from內部結果) ?? []; 151 | } 152 | -------------------------------------------------------------------------------- /src/lib/音韻地位.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { iter音韻地位 } from './資料'; 4 | import { 判斷規則列表, 邊緣地位種類指定, 音韻地位 } from './音韻地位'; 5 | 6 | // 由音韻地位得出各項音韻屬性 7 | 8 | test('測試「法」字對應的音韻地位的各項音韻屬性', t => { 9 | const 當前音韻地位 = 音韻地位.from描述('幫三C凡入'); 10 | 11 | // 基本音韻屬性(六個) 12 | t.is(當前音韻地位.母, '幫'); 13 | t.is(當前音韻地位.呼, null); 14 | t.is(當前音韻地位.等, '三'); 15 | t.is(當前音韻地位.類, 'C'); 16 | t.is(當前音韻地位.韻, '凡'); 17 | t.is(當前音韻地位.聲, '入'); 18 | 19 | // 拓展音韻屬性 20 | t.is(當前音韻地位.清濁, '全清'); 21 | t.is(當前音韻地位.音, '脣'); 22 | t.is(當前音韻地位.攝, '咸'); 23 | 24 | // 其他 25 | t.is(當前音韻地位.描述, '幫三C凡入'); 26 | t.is(當前音韻地位.簡略描述, '幫凡入'); 27 | t.is(當前音韻地位.表達式, '幫母 開合中立 三等 C類 凡韻 入聲'); 28 | 29 | t.true(當前音韻地位.等於(音韻地位.from描述('幫凡入', true))); 30 | }); 31 | 32 | test('測試「祇」字對應的音韻地位的各項音韻屬性', t => { 33 | const 當前音韻地位 = 音韻地位.from描述('羣開三A支平'); 34 | 35 | // 基本音韻屬性(六個) 36 | t.is(當前音韻地位.母, '羣'); 37 | t.is(當前音韻地位.呼, '開'); 38 | t.is(當前音韻地位.等, '三'); 39 | t.is(當前音韻地位.類, 'A'); 40 | t.is(當前音韻地位.韻, '支'); 41 | t.is(當前音韻地位.聲, '平'); 42 | 43 | // 拓展音韻屬性 44 | t.is(當前音韻地位.清濁, '全濁'); 45 | t.is(當前音韻地位.音, '牙'); 46 | t.is(當前音韻地位.攝, '止'); 47 | 48 | // 其他 49 | t.is(當前音韻地位.描述, '羣開三A支平'); 50 | t.is(當前音韻地位.簡略描述, '羣開A支平'); 51 | t.is(當前音韻地位.表達式, '羣母 開口 三等 A類 支韻 平聲'); 52 | 53 | t.true(當前音韻地位.等於(音韻地位.from描述('羣開A支平', true))); 54 | }); 55 | 56 | test('音韻地位.調整', t => { 57 | const 地位 = 音韻地位.from描述('幫三C元上'); 58 | t.is(地位.調整({ 聲: '平' }).描述, '幫三C元平'); 59 | t.is(地位.調整('平聲').描述, '幫三C元平'); 60 | t.throws(() => 地位.調整({ 母: '見' }), { message: /missing 呼/ }, '.調整() 會驗證新地位'); 61 | t.is(地位.調整({ 母: '見', 呼: '合' }).描述, '見合三C元上'); 62 | t.is(地位.調整('見母 合口').描述, '見合三C元上'); 63 | t.is(地位.調整('仙韻 A類').描述, '幫三A仙上'); 64 | t.throws(() => 地位.調整('壞耶'), { message: 'unrecognized expression: 壞耶' }); 65 | t.throws(() => 地位.調整('見影母'), { message: 'unrecognized expression: 見影母' }); 66 | t.throws(() => 地位.調整('見母 影母'), { message: 'duplicated assignment of 母' }); 67 | t.throws(() => 地位.調整('見母合口'), { message: 'unrecognized expression: 見母合口' }); 68 | t.is(地位.描述, '幫三C元上', '.調整() 不修改原對象'); 69 | }); 70 | 71 | // 屬於 72 | 73 | test('測試「法」字對應的音韻地位的屬於函式(基本用法)', t => { 74 | const 當前音韻地位 = 音韻地位.from描述('幫三C凡入'); 75 | t.true(當前音韻地位.屬於('幫母')); 76 | t.true(當前音韻地位.屬於('幫精組')); 77 | t.false(當前音韻地位.屬於('精組')); 78 | t.false(當前音韻地位.屬於('AB類')); 79 | t.true(當前音韻地位.屬於('C類')); 80 | t.true(當前音韻地位.屬於('BC類')); 81 | t.false(當前音韻地位.屬於('喉音')); 82 | t.true(當前音韻地位.屬於('仄聲')); 83 | t.false(當前音韻地位.屬於('舒聲')); 84 | t.true(當前音韻地位.屬於('清音')); 85 | t.false(當前音韻地位.屬於('全濁')); 86 | t.false(當前音韻地位.屬於('次濁')); 87 | t.true(當前音韻地位.屬於('開合中立')); 88 | t.false(當前音韻地位.屬於('開口 或 合口')); 89 | t.true(當前音韻地位.屬於('幫組 C類')); 90 | t.false(當前音韻地位.屬於('陰聲韻')); 91 | }); 92 | 93 | test('測試「法」字對應的音韻地位的屬於(複雜用法)及判斷函式', t => { 94 | const 當前音韻地位 = 音韻地位.from描述('幫三C凡入'); 95 | t.true(當前音韻地位.屬於('非 一等')); 96 | t.true(當前音韻地位.屬於('非 (一等)')); 97 | t.true(當前音韻地位.屬於('非 ((一等))')); 98 | t.true(當前音韻地位.屬於('非 (非 三等)')); 99 | t.true(當前音韻地位.屬於('非 非 非 一等')); 100 | t.true(當前音韻地位.屬於('三等 或 一等 且 來母')); // 「且」優先於「或」 101 | t.false(當前音韻地位.屬於('(三等 或 一等) 且 來母')); 102 | t.true(當前音韻地位.屬於`一四等 或 ${當前音韻地位.描述 === '幫三C凡入'}`); 103 | t.true(當前音韻地位.屬於`${() => '三等'} 或 ${() => '短路〔或〕'}`); 104 | t.false(當前音韻地位.屬於`非 ${() => '三等'} 且 ${() => '短路〔且〕'}`); 105 | t.throws(() => 當前音韻地位.屬於`${() => '三等'} 或 ${'立即求值'}`, { message: 'unrecognized test condition: 立即求值' }); 106 | t.is( 107 | 當前音韻地位.判斷( 108 | [ 109 | ['遇果假攝 或 支脂之佳韻', ''], 110 | ['蟹攝 或 微韻', 'i'], 111 | ['效流攝', 'u'], 112 | [ 113 | '深咸攝', 114 | [ 115 | ['舒聲', 'm'], 116 | ['入聲', 'p'], 117 | ], 118 | ], 119 | [ 120 | '臻山攝', 121 | [ 122 | ['舒聲', 'n'], 123 | ['入聲', 't'], 124 | ], 125 | ], 126 | [ 127 | '通江宕梗曾攝', 128 | [ 129 | ['舒聲', 'ng'], 130 | ['入聲', 'k'], 131 | ], 132 | ], 133 | ], 134 | '無韻尾規則', 135 | ), 136 | 'p', 137 | ); 138 | }); 139 | 140 | test('測試不合法表達式', t => { 141 | const 地位 = 音韻地位.from描述('幫三C凡入'); 142 | const is = 地位.屬於.bind(地位); 143 | t.throws(() => is``, { message: 'empty expression' }); 144 | t.throws(() => is`三等 且 ()`, { message: 'expect expression, got: )' }); 145 | t.throws(() => is`一等 或`, { message: 'expect expression, got: end of expression' }); 146 | t.throws(() => is`或 一等`, { message: 'expect expression, got: 或' }); 147 | t.throws(() => is`三等 且 (或 一等)`, { message: 'expect expression, got: 或' }); 148 | t.throws(() => is`三等 且 非`, { message: "expect operand or '(', got: end of expression" }); 149 | t.throws(() => is`桓韻`, { message: 'unknown 韻: 桓' }); 150 | t.throws(() => is`${'桓韻'}`, { message: 'unknown 韻: 桓' }); 151 | t.throws(() => is`三等 或 桓韻`, { message: 'unknown 韻: 桓' }); 152 | t.throws(() => is`重紐A類`, { message: 'unknown 類: 重, 紐' }); 153 | }); 154 | 155 | test('測試判斷式拋異常', t => { 156 | const 地位 = 音韻地位.from描述('幫三C凡入'); 157 | t.throws( 158 | () => 159 | 地位.判斷([ 160 | ['遇果假攝 或 支脂之佳韻', ''], 161 | // ... 162 | [ 163 | '深咸攝', 164 | [ 165 | ['舒聲', 'm'], 166 | ['促聲', 'p'], 167 | ], 168 | ], 169 | // ... 170 | ['短路!', ''], 171 | ]), 172 | { message: 'unknown 聲: 促' }, 173 | ); 174 | t.throws(() => 地位.判斷([] as 判斷規則列表, '壞耶'), { message: '壞耶' }); 175 | }); 176 | 177 | test('判斷式 null 與 fall through', t => { 178 | const 地位 = 音韻地位.from描述('幫三C凡入'); 179 | 180 | t.is(地位.判斷([]), null); 181 | t.is(地位.判斷([['見母', 42]]), null); 182 | 183 | const 規則 = [ 184 | ['幫組', []], 185 | ['幫母 凡韻', 43], 186 | ] as const; 187 | t.is(地位.判斷(規則), null); 188 | t.throws(() => 地位.判斷(規則, '壞耶'), { message: '壞耶' }); 189 | }); 190 | 191 | test('簡略描述', t => { 192 | const test簡略描述 = (描述: string, 簡略描述: string, message: string) => { 193 | const 地位 = 音韻地位.from描述(描述); 194 | t.is(地位.簡略描述, 簡略描述, 'to 簡略描述: ' + message); 195 | const 地位from簡略描述 = 音韻地位.from描述(簡略描述, true); 196 | t.is(地位.描述, 地位from簡略描述.描述, 'from 簡略描述: ' + message); 197 | }; 198 | test簡略描述('精開三鹽平', '精鹽平', '省略呼、等'); 199 | test簡略描述('見開二佳平', '見開佳平', '省略等(按韻)'); 200 | test簡略描述('章開三麻上', '章開麻上', '省略等(按聲母)'); 201 | test簡略描述('定開四脂去', '定開脂去', '省略等(特殊)'); 202 | test簡略描述('幫三C凡入', '幫凡入', '省略C類'); 203 | test簡略描述('見開三B庚平', '見開三庚平', '省略B類'); 204 | test簡略描述('明三A清平', '明清平', '省略等、A類'); 205 | test簡略描述('云合三B支去', '云支去', '省略呼、等、類'); 206 | test簡略描述('云合三B蒸入', '云B蒸入', '不省略類'); 207 | test簡略描述('見開一歌平', '見開一歌平', '不省略'); 208 | test簡略描述('端開四麻平', '端開四麻平', '不省略'); 209 | }); 210 | 211 | test('三十六字母、韻圖等', t => { 212 | t.is(音韻地位.from描述('幫三C凡入').字母, '非', '輕脣音'); 213 | t.is(音韻地位.from描述('幫三B真平').字母, '幫', '重脣音'); 214 | t.is(音韻地位.from描述('常開三清平').字母, '禪', '照三(母)'); 215 | t.is(音韻地位.from描述('常開三清平').韻圖等, '三', '照三(等)'); 216 | t.is(音韻地位.from描述('生開三庚平').字母, '審', '照二(母)'); 217 | t.is(音韻地位.from描述('生開三庚平').韻圖等, '二', '照二(等)'); 218 | t.is(音韻地位.from描述('精開三之上').韻圖等, '四', '精組列於四等'); 219 | t.is(音韻地位.from描述('羣開三A支平').韻圖等, '四', '重紐四等'); 220 | t.is(音韻地位.from描述('羣開三B支平').韻圖等, '三', '重紐三等'); 221 | t.is(音韻地位.from描述('幫三C陽入').韻圖等, '三', '無重紐三等'); 222 | t.is(音韻地位.from描述('見開三A幽上').韻圖等, '四', '幽韻列於四等'); 223 | t.is(音韻地位.from描述('明三A清平').韻圖等, '四', '清韻脣牙喉列於四等'); 224 | t.is(音韻地位.from描述('並三A陽上').韻圖等, '四', '特殊重紐四等'); 225 | t.is(音韻地位.from描述('云合三C虞平').字母, '喻', '喻三(母)'); 226 | t.is(音韻地位.from描述('云合三C虞平').韻圖等, '三', '喻三(等)'); 227 | t.is(音韻地位.from描述('以合三虞平').字母, '喻', '喻四(母)'); 228 | t.is(音韻地位.from描述('以合三虞平').韻圖等, '四', '喻四(等)'); 229 | }); 230 | 231 | test('使用「iter音韻地位」函式遍歷所有音韻地位', t => { 232 | for (const 當前音韻地位 of iter音韻地位()) { 233 | t.is(音韻地位.from描述(當前音韻地位.描述).描述, 當前音韻地位.描述); 234 | t.true(當前音韻地位.屬於(當前音韻地位.表達式)); 235 | } 236 | }); 237 | 238 | test('不合法音韻地位', t => { 239 | t.throws( 240 | () => new 音韻地位('章', '開', '三', null, '眞', '平'), 241 | { message: /unrecognized 韻: 眞 \(did you mean: 真\?\)/ }, 242 | '基本屬性取值有誤', 243 | ); 244 | function testCase(描述: string, expectedMessage: RegExp, testMessage?: string) { 245 | t.throws( 246 | () => 音韻地位.from描述(描述), 247 | { message: expectedMessage }, 248 | testMessage == null ? undefined : `不合法地位 '${描述}': ${testMessage}`, 249 | ); 250 | } 251 | testCase('精開三祭入', /unexpected 祭韻入聲/, '聲搭配'); 252 | testCase('莊開二陽平', /unexpected 陽韻二等/, '等搭配(韻)'); 253 | testCase('定開三脂去', /unexpected 定母三等/, '等搭配(端組)'); 254 | testCase('明合一魂平', /unexpected 呼/, '呼搭配(脣音)'); 255 | testCase('端開一東平', /unexpected 呼/, '呼搭配(開合中立韻)'); 256 | testCase('章三麻平', /missing 呼/, '呼搭配(分開合韻)'); 257 | testCase('見合三A幽上', /unexpected 幽韻合口/, '呼搭配(僅開/僅合口韻)'); 258 | testCase('幫四A先平', /unexpected 類/, '類搭配(等)'); 259 | testCase('章開三A支平', /unexpected 類/, '類搭配(母)'); 260 | testCase('云合三A支平', /unexpected 云母A類/, '類搭配(云母)'); 261 | testCase('明三清上', /missing 類 \(should be A\)/, '類搭配(韻)'); 262 | testCase('幫三B清入', /unexpected 清韻B類/, '類搭配(韻)'); 263 | testCase('明三陽去', /missing 類 \(should be C typically\)/, '類搭配(韻,可能有邊緣地位)'); 264 | testCase('幫三C嚴入', /unexpected 嚴韻脣音/, '母搭配(凡韻)'); 265 | testCase('幫三C之平', /unexpected 之韻脣音/, '母搭配(脣音之韻)'); 266 | testCase('初開三真去', /unexpected 真韻開口莊組/, '母搭配(臻韻)'); 267 | testCase('知開三庚平', /unexpected 庚韻三等知母/, '母搭配(非莊組銳聲母)'); 268 | }); 269 | 270 | test('蒸幽麻韻擴展地位', t => { 271 | for (const 描述 of [ 272 | // 蒸 273 | '影開三B蒸平', 274 | '影合三B蒸平', 275 | '影合三C蒸平', 276 | '知合三蒸平', 277 | '心合三蒸平', 278 | '生合三蒸平', 279 | // 幽 280 | '影開三B幽平', 281 | '知開三幽平', 282 | '心開三幽平', 283 | '章開三幽平', 284 | // 麻 285 | '並三B麻平', 286 | ]) { 287 | t.is(音韻地位.from描述(描述).描述, 描述); 288 | } 289 | // 蒸韻云母開口 290 | for (const 描述 of ['云開三B蒸平', '云開三C蒸平']) { 291 | t.throws(() => 音韻地位.from描述(描述), { message: /unexpected 云母開口 \(note: marginal 音韻地位/ }); 292 | t.is(音韻地位.from描述(描述, false, ['云母開口']).描述, 描述); 293 | } 294 | }); 295 | 296 | test('邊緣地位', t => { 297 | function passes(描述: string, 邊緣地位指定: 邊緣地位種類指定, testMessage?: string) { 298 | t.is( 299 | 音韻地位.from描述(描述, false, 邊緣地位指定).描述, 300 | 描述, 301 | testMessage == null ? undefined : `Should pass (${描述}): ${testMessage}`, 302 | ); 303 | } 304 | function throws(描述: string, 邊緣地位指定: 邊緣地位種類指定, expectedMessage: RegExp, testMessage?: string) { 305 | t.throws( 306 | () => 音韻地位.from描述(描述, false, 邊緣地位指定), 307 | { message: expectedMessage }, 308 | testMessage == null ? undefined : `Should throw (${描述}): ${testMessage}`, 309 | ); 310 | } 311 | 312 | throws('見一東平', ['壞耶'], /unknown type of marginal 音韻地位: 壞耶/, '未知邊緣地位類型'); 313 | 314 | // 嚴格邊緣地位 315 | passes('定開四脂去', [], '已知邊緣地位「地」'); 316 | passes('定開二佳上', [], '已知邊緣地位「箉」'); 317 | passes('端四尤平', [], '已知資料外邊緣地位「丟」'); 318 | 319 | throws('透開二佳上', [], /unexpected 佳韻二等透母/, '端組類隔(二等)'); 320 | throws('端開四清上', [], /unexpected 清韻四等端母/, '端組類隔(四等)'); 321 | passes('端開四清上', ['端組類隔'], '端組類隔'); 322 | throws('端開四青上', ['端組類隔'], /\(note: don't specify/, '非相關邊緣地位'); 323 | 324 | // 非嚴格邊緣地位 325 | throws('云開三C之平', [], /unexpected 云母開口 \(note: marginal 音韻地位/, '云母開口'); 326 | passes('云開三C之平', ['云母開口'], '云母開口'); 327 | passes('云合三B支平', ['云母開口'], '非嚴格音韻地位類型指定'); 328 | 329 | // 端組類隔 330 | throws('泥開四陽上', [], /unexpected 陽韻四等泥母/, '端組類隔(陽韻)'); 331 | passes('泥開四陽上', ['端組類隔'], '端組類隔(陽韻)'); 332 | throws('端開四庚上', ['端組類隔'], /unexpected 庚韻四等端母/, '庚三完全不允許端四'); 333 | 334 | // 匣母三等 335 | throws('匣三C東平', [], /unexpected 匣母三等/, '匣母三等'); 336 | passes('匣三C東平', ['匣母三等'], '匣母三等'); 337 | passes('匣開三A真平', ['匣母三等'], '匣母三等A類'); 338 | }); 339 | -------------------------------------------------------------------------------- /src/lib/音韻地位.ts: -------------------------------------------------------------------------------- 1 | import { assert } from './utils'; 2 | import { 母到清濁, 母到組, 母到音, 韻到攝 } from './拓展音韻屬性'; 3 | import { 呼韻搭配, 所有, 等母搭配, 等韻搭配, 鈍音母, 陰聲韻 } from './音韻屬性常量'; 4 | 5 | const pattern描述 = new RegExp( 6 | `^([${所有.母.join('')}])([${所有.呼.join('')}]?)([${所有.等.join('')}]?)` + 7 | `([${所有.類.join('')}]?)([${所有.韻.join('')}])([${所有.聲.join('')}])$`, 8 | 'u', 9 | ); 10 | 11 | // for 音韻地位.屬於 12 | const 表達式屬性可取值 = { 13 | ...所有, 14 | 音: [...'脣舌齒牙喉'] as const, 15 | 攝: [...'通江止遇蟹臻山效果假宕梗曾流深咸'] as const, 16 | 組: [...'幫端知精莊章見影'] as const, 17 | }; 18 | 19 | /** 20 | * @see {@link 音韻地位.判斷} 21 | */ 22 | export type 判斷規則列表 = readonly (readonly [unknown, T | 判斷規則列表])[]; 23 | 24 | /** 25 | * @see {@link 音韻地位.調整} 26 | */ 27 | export type 部分音韻屬性 = Partial>; 28 | 29 | /** 30 | * 建立 `音韻地位` 時,若建立的是邊緣音韻地位,需利用該類型的參數。 31 | * 參數為陣列,當中列明待建立的邊緣地位種類,以表明使用者知曉其為邊緣地位並確認建立。 32 | * 33 | * **注意**:內建的邊緣地位白名單**已涵蓋內建資料中全部邊緣地位**,故使用內建資料之音韻地位時完全不需使用此參數。 34 | * 35 | * 目前支持的種類如下: 36 | * - `'陽韻A類'` 37 | * - `'端組類隔'` 38 | * - `'咍韻脣音'` 39 | * - `'匣母三等'` 40 | * - `'羣邪俟母非三等'` 41 | * - `'云母開口'` (+) 42 | * 43 | * 未標注「(+)」者,僅可當待建立地位確實為該類型邊緣地位時,才可以列入,否則無法建立音韻地位。而標注「(+)」者則在建立任意音韻地位時均可列入。 44 | */ 45 | export type 邊緣地位種類指定 = readonly string[]; 46 | 47 | const 已知邊緣地位 = new Set([ 48 | // 嚴格邊緣地位 49 | // 陽韻A類 50 | '並三A陽上', // 𩦠 51 | // 端組類隔 52 | '定開四脂去', // 地 53 | '端開二庚上', // 打 54 | '端開二麻上', // 打(麻韻) 55 | '端開四麻平', // 爹 56 | '端開四麻上', // 嗲 57 | '定開二佳上', // 箉 58 | '端四尤平', // 丟 59 | // 咍韻脣音(無) 60 | // 匣母三等(無) 61 | // 羣邪俟母非三等(無) 62 | // ---- 63 | // 非嚴格邊緣地位 64 | // 云母開口 65 | '云開三C之上', // 矣 66 | '云開三B仙平', // 焉 67 | ]); 68 | 69 | export const _UNCHECKED: 邊緣地位種類指定 = ['@UNCHECKED@']; 70 | 71 | /** 72 | * 《切韻》音系音韻地位。 73 | * 74 | * 可使用字串 (母, 呼, 等, 類, 韻, 聲) 初始化。 75 | * 76 | * | 音韻屬性 | 中文名稱 | 英文名稱 | 可能取值 | 77 | * | :- | :- | :- | :- | 78 | * | 母
組 | 聲母
組 | initial
group | **幫**滂並明
**端**透定泥

**知**徹澄孃
**精**清從心邪
**莊**初崇生俟
**章**昌常書船

**見**溪羣疑
**影**曉匣云

(粗體字為組,未涵蓋「來日以」) | 79 | * | 呼 | 呼 | rounding | 開口
合口 | 80 | * | 等 | 等 | division | 一二三四 | 81 | * | 類 | 類 | type | ABC | 82 | * | 韻
攝 | 韻母
攝 | rime
class | 通:東冬鍾
江:江
止:支脂之微
遇:魚虞模
蟹:齊祭泰佳皆夬灰咍廢
臻:真臻文殷魂痕
山:元寒刪山先仙
效:蕭宵肴豪
果:歌
假:麻
宕:陽唐
梗:庚耕清青
曾:蒸登
流:尤侯幽
深:侵
咸:覃談鹽添咸銜嚴凡
(冒號前為攝,後為對應的韻) | 83 | * | 聲 | 聲調 | tone | 平上去入

舒 | 84 | * 85 | * 音韻地位六要素:母、呼、等、類、韻、聲。 86 | * 87 | * 「呼」和「類」可為 `null`,其餘四個屬性不可為 `null`。 88 | * 89 | * 當聲母為脣音,或韻母為「東冬鍾江模尤侯」(開合中立的韻)之一時,「呼」須為 `null`。 90 | * 在其他情況下,「呼」須取 `'開'` 或 `'合'`。 91 | * 92 | * 當聲母為鈍音(脣牙喉音,不含以母),且為三等韻時,「類」須取 `'A'`、`'B'`、`'C'` 之一。 93 | * 在其他情況下,「類」須為 `null`。 94 | * 95 | * 依切韻韻目,用殷韻不用欣韻;亦不設諄、桓、戈韻,分別併入真、寒、歌韻。 96 | * 97 | * 不支援異體字,請自行轉換: 98 | * 99 | * * 音 唇 → 脣 100 | * * 母 娘 → 孃 101 | * * 母 荘 → 莊 102 | * * 母 谿 → 溪 103 | * * 母 群 → 羣 104 | * * 韻 餚 → 肴 105 | * * 韻 眞 → 真 106 | */ 107 | export class 音韻地位 { 108 | /** 109 | * 聲母 110 | * @example 111 | * ```typescript 112 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 113 | * > 音韻地位.母; 114 | * '幫' 115 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 116 | * > 音韻地位.母; 117 | * '羣' 118 | * ``` 119 | */ 120 | readonly 母: string; 121 | 122 | /** 123 | * 呼 124 | * @example 125 | * ```typescript 126 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 127 | * > 音韻地位.呼; 128 | * null 129 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 130 | * > 音韻地位.呼; 131 | * '開' 132 | * ``` 133 | */ 134 | readonly 呼: string | null; 135 | 136 | /** 137 | * 等 138 | * @example 139 | * ```typescript 140 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 141 | * > 音韻地位.等; 142 | * '三' 143 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 144 | * > 音韻地位.等; 145 | * '三' 146 | * ``` 147 | */ 148 | readonly 等: string; 149 | 150 | /** 151 | * 類 152 | * - AB 類為前元音,在脣牙喉音有最小對立,此情形亦稱「重紐」 153 | * - C 類為非前元音 154 | * @example 155 | * ```typescript 156 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 157 | * > 音韻地位.類; 158 | * 'C' 159 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 160 | * > 音韻地位.類; 161 | * 'A' 162 | * > 音韻地位 = TshetUinh.音韻地位.from描述('章開三支平'); 163 | * > 音韻地位.類; 164 | * null 165 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫四先平'); 166 | * > 音韻地位.類; 167 | * null 168 | * ``` 169 | */ 170 | readonly 類: string | null; 171 | 172 | /** 173 | * 韻(舉平以賅上去入,唯祭、泰、夬、廢例外) 174 | * @example 175 | * ```typescript 176 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 177 | * > 音韻地位.韻; 178 | * '凡' 179 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 180 | * > 音韻地位.韻; 181 | * '支' 182 | * ``` 183 | */ 184 | readonly 韻: string; 185 | 186 | /** 187 | * 聲調 188 | * @example 189 | * ```typescript 190 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 191 | * > 音韻地位.聲; 192 | * '入' 193 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 194 | * > 音韻地位.聲; 195 | * '平' 196 | * ``` 197 | */ 198 | readonly 聲: string; 199 | 200 | /** 201 | * 初始化音韻地位物件。 202 | * @param 母 聲母:幫, 滂, 並, 明, … 203 | * @param 呼 呼:`null`, 開, 合 204 | * @param 等 等:一, 二, 三, 四 205 | * @param 類 類:`null`, A, B, C 206 | * @param 韻 韻母(平賅上去入):東, 冬, 鍾, 江, …, 祭, 泰, 夬, 廢 207 | * @param 聲 聲調:平, 上, 去, 入 208 | * @param 邊緣地位種類 建立邊緣地位時,列明該地位的邊緣地位種類 209 | * @returns 六要素所描述的音韻地位 210 | * @throws 待建立之音韻地位會透過{@link 驗證}檢驗音節合法性,不合法則拋出異常 211 | * @example 212 | * ```typescript 213 | * > new TshetUinh.音韻地位('幫', null, '三', 'C', '凡', '入'); 214 | * 音韻地位<幫三C凡入> 215 | * > new TshetUinh.音韻地位('羣', '開', '三', 'A', '支', '平'); 216 | * 音韻地位<羣開三A支平> 217 | * > new TshetUinh.音韻地位('章', '開', '三', null, '支', '平'); 218 | * 音韻地位<章開三支平> 219 | * > new TshetUinh.音韻地位('幫', null, '四', null, '先', '平'); 220 | * 音韻地位<幫四先平> 221 | * ``` 222 | */ 223 | constructor(母: string, 呼: string | null, 等: string, 類: string | null, 韻: string, 聲: string, 邊緣地位種類: 邊緣地位種類指定 = []) { 224 | 音韻地位.驗證(母, 呼, 等, 類, 韻, 聲, 邊緣地位種類); 225 | this.母 = 母; 226 | this.呼 = 呼; 227 | this.等 = 等; 228 | this.類 = 類; 229 | this.韻 = 韻; 230 | this.聲 = 聲; 231 | } 232 | 233 | /** 234 | * 清濁(全清、次清、全濁、次濁) 235 | * 236 | * 曉母為全清,云以來日母為次濁。 237 | * 238 | * @example 239 | * ```typescript 240 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 241 | * > 音韻地位.清濁; 242 | * '全清' 243 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 244 | * > 音韻地位.清濁; 245 | * '全濁' 246 | * ``` 247 | */ 248 | get 清濁(): string { 249 | const { 母 } = this; 250 | return 母到清濁[母]; 251 | } 252 | 253 | /** 254 | * 音(發音部位:脣、舌、齒、牙、喉) 255 | * 256 | * **注意**: 257 | * 258 | * * 不設半舌半齒音,來母歸舌音,日母歸齒音 259 | * * 以母不屬於影組,但屬於喉音 260 | * 261 | * @example 262 | * ```typescript 263 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 264 | * > 音韻地位.音; 265 | * '脣' 266 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 267 | * > 音韻地位.音; 268 | * '牙' 269 | * ``` 270 | */ 271 | get 音(): string { 272 | const { 母 } = this; 273 | return 母到音[母]; 274 | } 275 | 276 | /** 277 | * 攝 278 | * @example 279 | * ```typescript 280 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 281 | * > 音韻地位.攝; 282 | * '咸' 283 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 284 | * > 音韻地位.攝; 285 | * '止' 286 | * ``` 287 | */ 288 | get 攝(): string { 289 | const { 韻 } = this; 290 | return 韻到攝[韻]; 291 | } 292 | 293 | /** 294 | * 韻別(陰聲韻、陽聲韻、入聲韻) 295 | * @example 296 | * ```typescript 297 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 298 | * > 音韻地位.韻別; 299 | * '入' 300 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 301 | * > 音韻地位.韻別; 302 | * '陰' 303 | * ``` 304 | */ 305 | get 韻別(): string { 306 | const { 韻, 聲 } = this; 307 | return 陰聲韻.includes(韻) ? '陰' : 聲 === '入' ? '入' : '陽'; 308 | } 309 | 310 | /** 311 | * 組 312 | * @example 313 | * ```typescript 314 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 315 | * > 音韻地位.組; 316 | * '幫' 317 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 318 | * > 音韻地位.組; 319 | * '見' 320 | * ``` 321 | */ 322 | get 組(): string | null { 323 | const { 母 } = this; 324 | return 母到組[母]; 325 | } 326 | 327 | /** 328 | * 描述 329 | * @example 330 | * ```typescript 331 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 332 | * > 音韻地位.描述; 333 | * '幫三C凡入' 334 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 335 | * > 音韻地位.描述; 336 | * '羣開三A支平' 337 | * > 音韻地位 = TshetUinh.音韻地位.from描述('章開三支平'); 338 | * > 音韻地位.描述; 339 | * '章開三支平' 340 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫四先平'); 341 | * > 音韻地位.描述; 342 | * '幫四先平' 343 | * ``` 344 | */ 345 | get 描述(): string { 346 | const { 母, 呼, 等, 類, 韻, 聲 } = this; 347 | return 母 + (呼 ?? '') + 等 + (類 ?? '') + 韻 + 聲; 348 | } 349 | 350 | /** 351 | * 簡略描述。會省略可由「母」或由「韻」直接確定的「呼」「等」「類」。 352 | * 353 | * **注意**:此項尚未成為穩定功能,不要依賴其輸出值。 354 | * @example 355 | * ```typescript 356 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 357 | * > 音韻地位.簡略描述; 358 | * '幫凡入' 359 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 360 | * > 音韻地位.簡略描述; 361 | * '羣開A支平' 362 | * ``` 363 | */ 364 | get 簡略描述(): string { 365 | const { 母, 韻, 聲 } = this; 366 | let { 呼, 等, 類 } = this; 367 | if (類 && 類搭配(母, 韻)[0] === 類) { 368 | 類 = null; 369 | } 370 | if (呼 === '合' && 母 === '云') { 371 | 呼 = null; 372 | } else if (呼 && 呼韻搭配[呼 as '開' | '合'].includes(韻)) { 373 | 呼 = null; 374 | } 375 | if (等 === '三' && [...'羣邪俟'].includes(母)) { 376 | 等 = ''; 377 | } else if (等母搭配.三.includes(母) || ![...等韻搭配.一三, ...等韻搭配.二三].includes(韻)) { 378 | 等 = ''; 379 | } 380 | return 母 + (呼 ?? '') + 等 + (類 ?? '') + 韻 + 聲; 381 | } 382 | 383 | /** 384 | * 表達式,可用於{@link 屬於}函數 385 | * @example 386 | * ```typescript 387 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 388 | * > 音韻地位.表達式; 389 | * '幫母 開合中立 三等 C類 凡韻 入聲' 390 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 391 | * > 音韻地位.表達式; 392 | * '羣母 開口 三等 A類 支韻 平聲' 393 | * ``` 394 | */ 395 | get 表達式(): string { 396 | const { 母, 呼, 等, 類, 韻, 聲 } = this; 397 | const 呼字段 = 呼 ? `${呼}口 ` : '開合中立 '; 398 | const 類字段 = 類 ? `${類}類 ` : '不分類 '; 399 | return `${母}母 ${呼字段}${等}等 ${類字段}${韻}韻 ${聲}聲`; 400 | } 401 | 402 | /** 403 | * 三十六字母 404 | * @example 405 | * ```typescript 406 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 407 | * > 音韻地位.字母; 408 | * '非' 409 | * > 音韻地位 = TshetUinh.音韻地位.from描述('常開三清平'); 410 | * > 音韻地位.字母; 411 | * '禪' 412 | * > 音韻地位 = TshetUinh.音韻地位.from描述('俟開三之上'); 413 | * > 音韻地位.字等; 414 | * '禪' 415 | * ``` 416 | */ 417 | get 字母(): string { 418 | const { 母, 等, 類 } = this; 419 | let index: number; 420 | if (等 === '三' && 類 === 'C' && (index = [...'幫滂並明'].indexOf(母)) !== -1) { 421 | return '非敷奉微'[index]; 422 | } else if ((index = [...'莊初崇生俟章昌船書常'].indexOf(母)) !== -1) { 423 | return '照穿牀審禪'[index % 5]; 424 | } else if (['云', '以'].includes(母)) { 425 | return '喻'; 426 | } 427 | return 母; 428 | } 429 | 430 | /** 431 | * 韻圖等 432 | * @example 433 | * ```typescript 434 | * > 音韻地位 = TshetUinh.音韻地位.from描述('羣開三A支平'); 435 | * > 音韻地位.韻圖等; 436 | * '四' 437 | * > 音韻地位 = TshetUinh.音韻地位.from描述('常開三清平'); 438 | * > 音韻地位.韻圖等; 439 | * '三' 440 | * > 音韻地位 = TshetUinh.音韻地位.from描述('俟開三之上'); 441 | * > 音韻地位.韻圖等; 442 | * '二' 443 | * ``` 444 | */ 445 | get 韻圖等(): string { 446 | const { 母, 等, 類 } = this; 447 | if ([...'莊初崇生俟'].includes(母)) { 448 | return '二'; 449 | } else if (類 === 'A' || (等 === '三' && [...'精清從心邪以'].includes(母))) { 450 | return '四'; 451 | } else { 452 | return 等; 453 | } 454 | } 455 | 456 | /** 457 | * 調整該音韻地位的屬性,會驗證調整後地位的合法性,回傳新的物件。 458 | * 459 | * **注意**:原物件不會被修改。 460 | * 461 | * @param 調整屬性 可為以下種類之一: 462 | * - 物件,其屬性可為六項基本屬性中的若干項,各屬性的值為欲修改成的值。 463 | * 464 | * 不含某屬性或某屬性值為 `undefined` 則表示不修改該屬性。 465 | * 466 | * - 字串,可寫出若干項屬性,以空白分隔各項。各屬性的寫法如下: 467 | * - 母、等、韻、聲:如 `'見母'`、`'三等'`、`'元韻'`、`'平聲'` 等 468 | * - 呼:`'開口'`、`'合口'`、`'開合中立'` 469 | * - 類:`'A類'`、`'B類'`、`'C類'`、`'不分類'` 470 | * @param 邊緣地位種類 若調整後為邊緣地位,列明其種類 471 | * @returns 新的 `音韻地位`,其中會含有指定的修改值 472 | * @example 473 | * ```typescript 474 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C元上'); 475 | * > 音韻地位.調整({ 聲: '平' }).描述 476 | * '幫三C元平' 477 | * > 音韻地位.調整('平聲').描述 478 | * '幫三C元平' 479 | * > 音韻地位.調整({ 母: '見', 呼: '合' }).描述 480 | * '見合三C元上' 481 | * > 音韻地位.調整('見母 合口').描述 482 | * '見合三C元上' 483 | * ``` 484 | */ 485 | 調整(調整屬性: 部分音韻屬性 | string, 邊緣地位種類: 邊緣地位種類指定 = []): 音韻地位 { 486 | if (typeof 調整屬性 === 'string') { 487 | const 屬性object: { -readonly [k in keyof 部分音韻屬性]: 部分音韻屬性[k] } = {}; 488 | const set = (屬性: K, 值: 音韻地位[K]) => { 489 | assert(!(屬性 in 屬性object), () => `duplicated assignment of ${屬性}`); 490 | 屬性object[屬性] = 值; 491 | }; 492 | 493 | for (const token of 調整屬性.trim().split(/\s+/u)) { 494 | const match = /^(?開合中立|不分類)$|^(?.)(?[母口等類韻聲])$/u.exec(token); 495 | assert(match !== null, () => `unrecognized expression: ${token}`); 496 | const { kv, k, v } = match.groups!; 497 | if (kv) { 498 | if (kv === '開合中立') set('呼', null); 499 | else if (kv === '不分類') set('類', null); 500 | } else { 501 | set(k.replace('口', '呼') as keyof 部分音韻屬性, v); 502 | } 503 | } 504 | 505 | 調整屬性 = 屬性object; 506 | } 507 | const { 母 = this.母, 呼 = this.呼, 等 = this.等, 類 = this.類, 韻 = this.韻, 聲 = this.聲 } = 調整屬性; 508 | return new 音韻地位(母, 呼, 等, 類, 韻, 聲, 邊緣地位種類); 509 | } 510 | 511 | /** 512 | * 判斷某個小韻是否屬於給定的音韻地位限定範圍。 513 | * 514 | * 本方法可使用一般形式(`.屬於('...')`)或標籤模板語法(`` .屬於`...` ``)。 515 | * 516 | * 標籤模板語法僅能用於字面值的字串,但寫出來較簡單清晰。在不嵌入參數時,兩者效果相同。建議當表達式為字面值時使用標籤模板語法。 517 | * 518 | * @param 表達式 描述音韻地位的字串 519 | * 520 | * 字串中音韻地位的描述格式: 521 | * 522 | * * 音韻地位六要素: 523 | * * `……母`, `……等`, `……韻`, `……聲` 524 | * * 呼:`開口`, `合口`, `開合中立` 525 | * * 類:`A類`, `B類`, `C類`, `不分類`(其中 ABC 可組合書寫,如 `AC類`) 526 | * * 拓展音韻地位: 527 | * * `……組`, `……音`, `……攝` 528 | * * 清濁:`全清`, `次清`, `全濁`, `次濁`, `清音`, `濁音` 529 | * * 韻別:`陰聲韻`, `陽聲韻`, `入聲韻` 530 | * * 其他表達式: 531 | * * `仄聲`:上去入聲 532 | * * `舒聲`:平上去聲 533 | * * `鈍音`:幫見影組 534 | * * `銳音`:鈍音以外聲母 535 | * 536 | * 支援的運算子: 537 | * 538 | * * AND 運算子:`且`, `and`, `&`, `&&` 539 | * *   OR 運算子:`或`, `or`, `|`, `||` 540 | * * NOT 運算子:`非`, `not`, `~`, `!` 541 | * * 括號:`(……)`, `(……)` 542 | * 543 | * 各表達式及運算子之間須以空格隔開。 544 | * 545 | * AND 運算子可省略,如 `(端精組 且 入聲) 或 (以母 且 四等 且 去聲)` 與 `端精組 入聲 或 以母 四等 去聲` 同義。 546 | * @returns 若描述音韻地位的字串符合該音韻地位,回傳 `true`;否則回傳 `false`。 547 | * @throws 若表達式為空、不合語法、或限定條件不合法,則拋出異常。 548 | * @example 549 | * ```typescript 550 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 551 | * > 音韻地位.屬於`章母`; // 標籤模板語法(表達式為字面值時推荐) 552 | * false 553 | * > 音韻地位.屬於('章母'); // 一般形式 554 | * false 555 | * > 音韻地位.屬於`一四等`; 556 | * false 557 | * > 音韻地位.屬於`幫組 或 陽韻`; 558 | * true 559 | * ``` 560 | */ 561 | 屬於(表達式: string): boolean; 562 | 563 | /** 564 | * 判斷某個小韻是否屬於給定的音韻地位限定範圍(標籤模板語法)。 565 | * 566 | * 嵌入的參數可以是: 567 | * 568 | * * 函數:會被執行;若其傳回值為字串,會視作表達式,遞迴套用{@link 屬於}來判斷,否則會直接檢測其真值 569 | * * 字串:視作表達式,遞迴套用{@link 屬於} 570 | * * 其他:會檢測其真值 571 | * 572 | * **注意**: 573 | * 574 | * * 該語法僅能用於字面值模板串,不能用於如{@link 音韻地位.判斷}等 575 | * * `` .屬於`${...}` `` 和 `` .屬於(`${...}`) `` 不同,只有前者支持上述嵌入參數,後者的模板串會先被求值為普通字串。 576 | * 577 | * @param 表達式 描述音韻地位的模板字串列表。 578 | * @param 參數 要嵌入模板的參數列表。 579 | * @returns 若描述音韻地位的字串符合該音韻地位,回傳 `true`;否則回傳 `false`。 580 | * @throws 若表達式為空、不合語法、或限定條件不合法,則拋出異常。 581 | * @example 582 | * ```typescript 583 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三凡入'); 584 | * > 音韻地位.屬於`一四等 或 ${音韻地位.描述 === '幫三凡入'}`; 585 | * true 586 | * ``` 587 | */ 588 | 屬於(表達式: TemplateStringsArray, ...參數: unknown[]): boolean; 589 | 590 | 屬於(表達式: string | readonly string[], ...參數: unknown[]): boolean { 591 | if (typeof 表達式 === 'string') 表達式 = [表達式]; 592 | 593 | /** 普通字串 token 求值 */ 594 | const { 母, 呼, 類, 聲, 清濁, 韻別 } = this; 595 | const evalToken = (token: string): boolean => { 596 | let match: RegExpExecArray | null = null; 597 | if ((match = /^(陰|陽|入)聲韻$/.exec(token))) return 韻別 === match[1]; 598 | if (token === '仄聲') return 聲 !== '平'; 599 | if (token === '舒聲') return 聲 !== '入'; 600 | if ((match = /^(開|合)口$/.exec(token))) return 呼 === match[1]; 601 | if (/^開合中立$/.exec(token)) return 呼 === null; 602 | if (/^不分類$/.exec(token)) return 類 === null; 603 | if ((match = /^(清|濁)音$/.exec(token))) return 清濁[1] === match[1]; 604 | if ((match = /^[全次][清濁]$/.exec(token))) return 清濁 === match[0]; 605 | if (token === '鈍音') return 鈍音母.includes(母); 606 | if (token === '銳音') return !鈍音母.includes(母); 607 | if ((match = /^(.+?)([母等類韻音攝組聲])$/.exec(token))) { 608 | const values = [...match[1]]; 609 | const key = match[2] as keyof typeof 表達式屬性可取值; 610 | const possibleValues = 表達式屬性可取值[key]; 611 | const invalidValues = values.filter(i => !possibleValues.includes(i)); 612 | if (invalidValues.length) { 613 | throw new Error(`unknown ${key}: ${invalidValues.join(', ')}`); 614 | } 615 | return values.includes(this[key]!); 616 | } 617 | throw new Error(`unrecognized test condition: ${token}`); 618 | }; 619 | 620 | // 詞法分析,同時給普通運算元求值(惟函數型運算元留待後面惰性求值) 621 | type Keyword = '(' | ')' | 'not' | 'and' | 'or' | 'end'; 622 | type Token = Keyword | boolean | LazyParameter; 623 | const KEYWORDS = ['(', ')', 'not', 'and', 'or'] as const; 624 | const PATTERNS = [/^\($/, /^\)$/, /^([!~非]|not)$/i, /^(&+|且|and)$/i, /^(\|+|或|or)$/i] as const; 625 | const tokens: [Token, string][] = []; 626 | for (let i = 0; i < 表達式.length; i++) { 627 | for (const rawToken of 表達式[i].split(/(&+|\|+|[!~()])|\b(and|or|not)\b|\s+/i).filter(i => i)) { 628 | const match = PATTERNS.findIndex(pat => pat.test(rawToken)); 629 | if (match !== -1) { 630 | tokens.push([KEYWORDS[match], rawToken]); 631 | } else { 632 | tokens.push([evalToken(rawToken), rawToken]); 633 | } 634 | } 635 | if (i < 參數.length) { 636 | const arg = LazyParameter.from(參數[i], this); 637 | tokens.push([arg, String(arg)]); 638 | } 639 | } 640 | assert(tokens.length, 'empty expression'); 641 | 642 | // 句法分析 643 | // 由於是 LL(1) 文法,可用遞迴下降法 644 | // 基本成分:元(boolean | LazyParameter)、非、且、或、'('、')' 645 | // 文法: 646 | // - 非項:非* ( 元 | 括號項 ) 647 | // - 且項:非項 ( 且? 非項 )* 648 | // - 或項:且項 ( 或 且項 )* 649 | // - 括號項:'(' 或項 ')' 650 | let cursor = 0; 651 | const END: [Token, string] = ['end', 'end of expression']; 652 | const peek = () => (cursor < tokens.length ? tokens[cursor] : END); 653 | const read = () => (cursor < tokens.length ? tokens[cursor++] : END); 654 | 655 | type Operand = boolean | LazyParameter | SExpr; 656 | type Operator = 'value' | 'not' | 'and' | 'or'; 657 | type SExpr = [Operator, ...Operand[]]; 658 | 659 | function parseOrExpr(required: T): T extends true ? SExpr : SExpr | null; 660 | function parseOrExpr(required: boolean): SExpr | null { 661 | const firstAndExpr = parseAndExpr(required); 662 | if (!firstAndExpr) { 663 | return null; 664 | } 665 | const orExpr: SExpr = ['or', firstAndExpr]; 666 | for (;;) { 667 | // 或 且項 | END | else 668 | const [token] = peek(); 669 | if (token === 'or') { 670 | cursor++; 671 | orExpr.push(parseAndExpr(true)); 672 | } else { 673 | return orExpr; 674 | } 675 | } 676 | } 677 | 678 | function parseAndExpr(required: T): T extends true ? SExpr : SExpr | null; 679 | function parseAndExpr(required: boolean): SExpr | null { 680 | const firstNotExpr = parseNotExpr(required); 681 | if (!firstNotExpr) { 682 | return null; 683 | } 684 | const andExpr: SExpr = ['and', firstNotExpr]; 685 | for (;;) { 686 | // 且? 非項 | END | else 687 | const [token] = peek(); 688 | if (token === 'and') { 689 | cursor++; 690 | andExpr.push(parseNotExpr(true)); 691 | } else { 692 | const notExpr = parseNotExpr(false); 693 | if (notExpr) { 694 | andExpr.push(notExpr); 695 | } else { 696 | return andExpr; 697 | } 698 | } 699 | } 700 | } 701 | 702 | function parseNotExpr(required: T): T extends true ? SExpr : SExpr | null; 703 | function parseNotExpr(required: boolean): SExpr | null { 704 | // 非* 705 | let seenNotOperator = false; 706 | let negate = false; 707 | for (;;) { 708 | const [token] = peek(); 709 | if (token === 'not') { 710 | seenNotOperator = true; 711 | negate = !negate; 712 | cursor++; 713 | } else { 714 | break; 715 | } 716 | } 717 | let valExpr: SExpr = [negate ? 'not' : 'value']; 718 | // 元 | 括號項 | else 719 | const [token, rawToken] = peek(); 720 | if (typeof token === 'boolean' || token instanceof LazyParameter) { 721 | valExpr.push(token); 722 | cursor++; 723 | return valExpr; 724 | } else if (token === '(') { 725 | cursor++; 726 | const parenExpr = parseOrExpr(true); 727 | const [rightParen, rawRightParen] = read(); 728 | if (rightParen !== ')') { 729 | throw new Error(`expect ')', got: ${rawRightParen}`); 730 | } 731 | if (negate) { 732 | valExpr.push(parenExpr); 733 | } else { 734 | valExpr = parenExpr; 735 | } 736 | return valExpr; 737 | } else if (seenNotOperator || required) { 738 | const expected = seenNotOperator ? "operand or '('" : 'expression'; 739 | throw new Error(`expect ${expected}, got: ${rawToken}`); 740 | } else { 741 | return null; 742 | } 743 | } 744 | 745 | const expr = parseOrExpr(true); 746 | const [token, rawToken] = read(); 747 | if (token !== 'end') { 748 | throw new Error(`unexpected token: ${rawToken}`); 749 | } 750 | 751 | // 求值 752 | const evalExpr = (expr: SExpr): boolean => { 753 | const [op, ...args] = expr; 754 | switch (op) { 755 | case 'value': 756 | return evalOperand(args[0]); 757 | case 'not': 758 | return !evalOperand(args[0]); 759 | case 'and': 760 | return args.every(evalOperand); 761 | case 'or': 762 | return args.some(evalOperand); 763 | } 764 | }; 765 | 766 | const evalOperand = (operand: Operand): boolean => 767 | typeof operand === 'boolean' ? operand : operand instanceof LazyParameter ? operand.eval() : evalExpr(operand); 768 | 769 | return evalExpr(expr); 770 | } 771 | 772 | /** 773 | * 判斷音韻地位是否符合給定的一系列判斷條件之一,傳回第一個符合的判斷條件所對應的自訂值。 774 | * @param 規則 `[判斷式, 結果][]` 形式的陣列。 775 | * 776 | * 判斷式可以是: 777 | * 778 | * *   函數:會被執行;若其傳回值為非空字串,會套用至{@link 音韻地位.屬於}函數判斷是否符合,若為布林值則由該值決定是否符合本條件,若為其他值則直接視作符合本條件 779 | * * 非空字串:描述音韻地位的表達式,會套用至{@link 音韻地位.屬於}函數 780 | * *  布林值:直接決定是否符合本條件 781 | * *   其他:均視作符合(可用於當其他條件均不滿足時,指定後備結果) 782 | * 783 | * 建議使用空字串、`null` 或 `true` 作末項判斷式以指定後備結果。 784 | * 785 | * 結果可以是任意傳回值或遞迴規則。 786 | * @param throws 若為 `true` 或字串,在未涵蓋所有條件時會拋出錯誤;用字串可指定錯誤情報 787 | * @param fallThrough 若為 `true`,在遞迴子陣列未涵蓋所有條件時會繼續嘗試母陣列的下一條件 788 | * @returns 自訂值,在未涵蓋所有條件且不使用 `error` 時會回傳 `null` 789 | * @throws `未涵蓋所有條件`(或 `error` 參數之文字),或套用至 `.屬於` 時出現的異常 790 | * @example 791 | * ```typescript 792 | * > 音韻地位 = TshetUinh.音韻地位.from描述('幫三C凡入'); 793 | * > 音韻地位.判斷([ 794 | * > ['遇果假攝 或 支脂之佳韻', ''], 795 | * > ['蟹攝 或 微韻', 'i'], 796 | * > ['效流攝', 'u'], 797 | * > ['深咸攝', [ 798 | * > ['舒聲', 'm'], 799 | * > ['入聲', 'p'] 800 | * > ]], 801 | * > ['臻山攝', [ 802 | * > ['舒聲', 'n'], 803 | * > ['入聲', 't'] 804 | * > ]], 805 | * > ['通江宕梗曾攝', [ 806 | * > ['舒聲', 'ng'], 807 | * > ['入聲', 'k'] 808 | * > ]] 809 | * > ], '無韻尾規則') 810 | * 'p' 811 | * ``` 812 | */ 813 | 判斷( 814 | 規則: 判斷規則列表, 815 | throws?: E, 816 | fallThrough?: boolean, 817 | ): E extends true | string ? T : T | null; 818 | 判斷(規則: 判斷規則列表, throws: boolean | string = false, fallThrough = false): T | null { 819 | const Exhaustion = Symbol('Exhaustion'); 820 | function is規則列表(obj: T | 判斷規則列表): obj is 判斷規則列表 { 821 | return Array.isArray(obj); 822 | } 823 | const loop = (所有規則: 判斷規則列表): T | typeof Exhaustion => { 824 | for (const 規則 of 所有規則) { 825 | assert(Array.isArray(規則) && 規則.length === 2, '規則需符合格式'); 826 | let 表達式 = 規則[0]; 827 | const 結果 = 規則[1]; 828 | if (typeof 表達式 === 'function') 表達式 = 表達式(); 829 | if (typeof 表達式 === 'string' && 表達式 ? this.屬於(表達式) : 表達式 !== false) { 830 | if (!is規則列表(結果)) return 結果; 831 | const res = loop(結果); 832 | if (res === Exhaustion && fallThrough) continue; 833 | return res; 834 | } 835 | } 836 | return Exhaustion; 837 | }; 838 | 839 | const res = loop(規則); 840 | if (res === Exhaustion) { 841 | if (throws === false) return null; 842 | else throw new Error(typeof throws === 'string' ? throws : '未涵蓋所有條件'); 843 | } 844 | return res; 845 | } 846 | 847 | /** 848 | * 判斷當前音韻地位是否等於另一音韻地位。 849 | * @param other 另一音韻地位。 850 | * @returns 若相等,則回傳 `true`;否則回傳 `false`。 851 | * @example 852 | * ```typescript 853 | * > a = TshetUinh.音韻地位.from描述('羣開三A支平'); 854 | * > b = TshetUinh.音韻地位.from描述('羣開三A支平'); 855 | * > a === b; 856 | * false 857 | * > a.等於(b); 858 | * true 859 | * ``` 860 | */ 861 | 等於(other: 音韻地位): boolean { 862 | return this.描述 === other.描述; 863 | } 864 | 865 | /** 同 {@link 描述} */ 866 | toString(): string { 867 | return this.描述; 868 | } 869 | 870 | /** @ignore 用於 Object.prototype.toString */ 871 | readonly [Symbol.toStringTag] = '音韻地位'; 872 | 873 | /** @ignore 僅用於 Node.js 呈現格式 */ 874 | [Symbol.for('nodejs.util.inspect.custom')](...args: unknown[]): string { 875 | const stylize = (...x: unknown[]) => (args[1] as { stylize(...x: unknown[]): string }).stylize(...x); 876 | return `音韻地位<${stylize(this.描述, 'string')}>`; 877 | } 878 | 879 | /** 880 | * 驗證給定的音韻地位六要素是否合法。 881 | * 882 | * ### 基本取值 883 | * 884 | * 母必須為「幫滂並明端透定泥來知徹澄孃精清從心邪莊初崇生俟章昌常書船日見溪羣疑影曉匣云以」之一。 885 | * 886 | * 韻必須為「東冬鍾江支脂之微魚虞模齊祭泰佳皆夬灰咍廢真臻文殷元魂痕寒刪山先仙蕭宵肴豪歌麻陽唐庚耕清青蒸登尤侯幽侵覃談鹽添咸銜嚴凡」之一。 887 | * 888 | * 當聲母為脣音,或韻母為「東冬鍾江虞模尤幽」(開合中立的韻)時,呼須為 `null`。 889 | * 在其他情況下,呼須取「開」或「合」。 890 | * 891 | * 當聲母為脣牙喉音(不含以母),且為三等韻時,類須取 `A`、`B`、`C` 之一。 892 | * 在其他情況下,類須為 `null`。 893 | * 894 | * ### 搭配 895 | * 896 | * 等: 897 | * - 章組、云以日母:限三等 898 | * - 羣邪俟母:一般限三等 899 | * - 匣母:一般限非三等 900 | * - 端組:限非三等,一般限一四等 901 | * - 精組(邪母除外):限一三四等 902 | * - 知莊組(俟母除外):限二三等 903 | * - 此外等當須與韻搭配 904 | * 905 | * 呼: 906 | * - 脣音、或開合中立韻:限 `null`(開合中立) 907 | * - 云母:除效流深咸四攝外,限非開口 908 | * - 其餘情形:呼須取「開」或「合」 909 | * 910 | * 類: 911 | * - 限幫見影組三等,其餘情形均須取 `null`(不分類) 912 | * - 前元音韻(支脂祭真仙宵麻庚清幽侵):須取 A 或 B,其中清韻限 A 類,庚韻限 B 類 913 | * - 其餘韻一般須取 C 914 | * - 蒸韻:須取 C 或 B 915 | * - 陽韻:限 C 類,但有取 A 類之罕見例外 916 | * - 云母:限非 A 類 917 | * 918 | * 韻: 919 | * - 凡韻:限脣音 920 | * - 嚴韻、之魚殷痕韻:限非脣音 921 | * - 臻韻:限莊組 922 | * - 真殷韻開口、清韻:限非莊組 923 | * - 庚韻非二等:銳音限莊組 924 | * 925 | * @param 母 聲母:幫, 滂, 並, 明, … 926 | * @param 呼 呼:`null`, 開, 合 927 | * @param 等 等:一, 二, 三, 四 928 | * @param 類 類:`null`, A, B, C 929 | * @param 韻 韻母(舉平以賅上去入):東, 冬, 鍾, 江, …, 祭, 泰, 夬, 廢 930 | * @param 聲 聲調:平, 上, 去, 入 931 | * @param 邊緣地位種類 若為邊緣地位,列明其種類 932 | * @throws 若給定的音韻地位六要素不合法,則拋出異常 933 | */ 934 | static 驗證( 935 | 母: string, 936 | 呼: string | null, 937 | 等: string, 938 | 類: string | null, 939 | 韻: string, 940 | 聲: string, 941 | 邊緣地位種類: 邊緣地位種類指定 = [], 942 | ): void { 943 | const reject = (msg: string) => { 944 | throw new Error(`invalid 音韻地位 <${母},${呼 ?? ''},${等},${類 ?? ''},${韻},${聲}>: ` + msg); 945 | }; 946 | 947 | // 驗證取值 948 | for (const [屬性, 值, nullable] of [ 949 | ['母', 母], 950 | ['呼', 呼, true], 951 | ['等', 等], 952 | ['類', 類, true], 953 | ['韻', 韻], 954 | ['聲', 聲], 955 | ] as const) { 956 | if (!((值 === null && !!nullable) || 所有[屬性].includes(值!))) { 957 | const suggestion = ( 958 | { 959 | 母: { 娘: '孃', 群: '羣' }, 960 | 韻: { 眞: '真', 欣: '殷' }, 961 | } as Record> 962 | )[屬性]?.[值!]; 963 | reject(`unrecognized ${屬性}: ${值}` + (suggestion ? ` (did you mean: ${suggestion}?)` : '')); 964 | } 965 | } 966 | 967 | // 驗證搭配 968 | // 順序:搭配規則從基本到精細 969 | 970 | // 聲(僅韻-聲搭配) 971 | 聲 === '入' && 陰聲韻.includes(韻) && reject(`unexpected ${韻}韻入聲`); 972 | 973 | // 等、呼、類(基本) 974 | // 母-等 975 | for (const [搭配等, 搭配母] of Object.entries(等母搭配)) { 976 | if (搭配母.includes(母)) { 977 | [...搭配等].includes(等) || reject(`unexpected ${母}母${等}等`); 978 | } 979 | } 980 | // 等-韻 981 | for (const [搭配各等, 搭配各韻] of Object.entries(等韻搭配)) { 982 | if (搭配各韻.includes(韻)) { 983 | if ([...搭配各等].includes(等)) { 984 | break; 985 | } else if (搭配各等.includes('三') && 等 === '四' && [...'端透定泥'].includes(母)) { 986 | break; 987 | } 988 | reject(`unexpected ${韻}韻${等}等`); 989 | } 990 | } 991 | // 母-呼(基本)、呼-韻 992 | if ([...'幫滂並明'].includes(母)) { 993 | 呼 && reject('unexpected 呼 for 脣音'); 994 | } else if (呼韻搭配.中立.includes(韻)) { 995 | 呼 && reject('unexpected 呼 for 開合中立韻'); 996 | } else if (呼韻搭配.開合.includes(韻)) { 997 | 呼 ?? reject('missing 呼'); 998 | } else { 999 | for (const 搭配呼 of ['開', '合'] as const) { 1000 | if (呼韻搭配[搭配呼].includes(韻)) { 1001 | if (呼 === 搭配呼) { 1002 | break; 1003 | } else if (呼) { 1004 | reject(`unexpected ${韻}韻${呼}口`); 1005 | } else { 1006 | reject(`missing 呼 (should be ${搭配呼})`); 1007 | } 1008 | } 1009 | } 1010 | } 1011 | // 母-類(基本)、等-類、類-韻(基本) 1012 | if (等 !== '三') { 1013 | 類 && reject('unexpected 類 for 非三等'); 1014 | } else if (!鈍音母.includes(母)) { 1015 | 類 && reject('unexpected 類 for 銳音聲母'); 1016 | } else { 1017 | const [典型搭配類, 搭配類] = 類搭配(母, 韻); 1018 | if (!類) { 1019 | const suggestion = 典型搭配類.length === 1 ? ` (should be ${典型搭配類}${典型搭配類 !== 搭配類 ? ' typically' : ''})` : ''; 1020 | reject(`missing 類${suggestion}`); 1021 | } else if (!搭配類.includes(類)) { 1022 | if (母 === '云' && 類 === 'A') { 1023 | reject(`unexpected 云母A類`); 1024 | } 1025 | reject(`unexpected ${韻}韻${類}類`); 1026 | } 1027 | } 1028 | 1029 | // 母-韻 1030 | if ([...'幫滂並明'].includes(母)) { 1031 | [...'之魚殷痕嚴'].includes(韻) && reject(`unexpected ${韻}韻脣音`); 1032 | } else { 1033 | 韻 === '凡' && reject(`unexpected 凡韻非脣音`); 1034 | } 1035 | if ([...'莊初崇生俟'].includes(母)) { 1036 | 等 === '三' && 韻 === '清' && reject(`unexpected ${韻}韻莊組`); 1037 | 呼 === '開' && ['真', '殷'].includes(韻) && reject(`unexpected ${韻}韻開口莊組`); 1038 | } else { 1039 | 韻 === '臻' && reject(`unexpected 臻韻非莊組`); 1040 | 韻 === '庚' && 等 !== '二' && !鈍音母.includes(母) && reject(`unexpected 庚韻${等}等${母}母`); 1041 | } 1042 | 1043 | // 邊緣搭配 1044 | 1045 | // 為已知邊緣地位,或特別指定跳過檢查 1046 | if (邊緣地位種類 === _UNCHECKED || 已知邊緣地位.has(母 + (呼 ?? '') + 等 + (類 ?? '') + 韻 + 聲)) { 1047 | return; 1048 | } 1049 | 1050 | const 邊緣地位指定集 = new Set(邊緣地位種類); 1051 | assert(邊緣地位種類.length === 邊緣地位指定集.size, 'duplicates in 邊緣地位種類'); 1052 | 1053 | const marginalTests = [ 1054 | ['陽韻A類', true, 韻 === '陽' && 類 === 'A', '陽韻A類'], 1055 | [ 1056 | '端組類隔', 1057 | true, 1058 | [...'端透定泥'].includes(母) && (等 === '二' || (等 === '四' && !等韻搭配.四.includes(韻))), 1059 | `${韻}韻${等}等${母}母`, 1060 | ], 1061 | ['咍韻脣音', true, 韻 === '咍' && [...'幫滂並明'].includes(母), `咍韻脣音`], 1062 | ['匣母三等', true, 母 === '匣' && 等 === '三', `匣母三等`], 1063 | ['羣邪俟母非三等', true, 等 !== '三' && [...'羣邪俟'].includes(母), `${母}母${等}等`], 1064 | ['云母開口', false, 母 === '云' && 呼 === '開' && ![...'宵幽侵鹽嚴'].includes(韻), '云母開口'], 1065 | ] as const; 1066 | 1067 | const knownKinds: string[] = marginalTests.map(([kind]) => kind); 1068 | for (const kind of 邊緣地位種類) { 1069 | if (!knownKinds.includes(kind)) { 1070 | throw new Error(`unknown type of marginal 音韻地位: ${kind}`); 1071 | } 1072 | } 1073 | 1074 | for (const [kind, isStrict, condition, errmsg] of marginalTests) { 1075 | if (condition && !邊緣地位指定集.has(kind)) { 1076 | const suggestion = isStrict ? '' : ` (note: marginal 音韻地位, include '${kind}' in 邊緣地位種類 to allow)`; 1077 | reject(`unexpected ${errmsg}${suggestion}`); 1078 | } else if (isStrict && !condition && 邊緣地位指定集.has(kind)) { 1079 | reject(`expect marginal 音韻地位: ${kind} (note: don't specify it in 邊緣地位種類 unless it describes this 音韻地位)`); 1080 | } 1081 | } 1082 | } 1083 | 1084 | /** 1085 | * 將音韻描述或簡略音韻描述轉換為音韻地位。 1086 | * @param 音韻描述 音韻地位的描述 1087 | * @param 簡略描述 為 `true` 則允許簡略描述,否則須為完整描述 1088 | * @returns 給定的音韻描述或最簡描述對應的音韻地位 1089 | * @example 1090 | * ```typescript 1091 | * > TshetUinh.音韻地位.from描述('幫三C凡入'); 1092 | * 音韻地位<幫三C凡入> 1093 | * > TshetUinh.音韻地位.from描述('幫凡入', true); 1094 | * 音韻地位<幫三C凡入> 1095 | * > TshetUinh.音韻地位.from描述('羣開三A支平'); 1096 | * 音韻地位<羣開三A支平> 1097 | * ``` 1098 | */ 1099 | static from描述(音韻描述: string, 簡略描述 = false, 邊緣地位種類: 邊緣地位種類指定 = []): 音韻地位 { 1100 | const match = pattern描述.exec(音韻描述); 1101 | if (!match) { 1102 | throw new Error(`invalid 描述: ${音韻描述}`); 1103 | } 1104 | const 母 = match[1]; 1105 | let 呼 = match[2] || null; 1106 | let 等 = match[3] || null; 1107 | let 類 = match[4] || null; 1108 | const 韻 = match[5]; 1109 | const 聲 = match[6]; 1110 | 1111 | if (簡略描述) { 1112 | if (!呼 && ![...'幫滂並明'].includes(母)) { 1113 | if (母 === '云' && 呼韻搭配.開合.includes(韻)) { 1114 | 呼 = '合'; 1115 | } else { 1116 | for (const 搭配呼 of ['開', '合'] as const) { 1117 | if (呼韻搭配[搭配呼].includes(韻)) { 1118 | 呼 = 搭配呼; 1119 | break; 1120 | } 1121 | } 1122 | } 1123 | } 1124 | 1125 | if (!等) { 1126 | if ([...等母搭配.三, ...'羣邪俟'].includes(母)) { 1127 | 等 = '三'; 1128 | } else { 1129 | for (const 搭配等 of ['一', '二', '三', '四'] as const) { 1130 | if (等韻搭配[搭配等].includes(韻)) { 1131 | if (搭配等 === '三' && [...'端透定泥'].includes(母)) { 1132 | 等 = '四'; 1133 | } else { 1134 | 等 = 搭配等; 1135 | } 1136 | break; 1137 | } 1138 | } 1139 | } 1140 | } 1141 | 1142 | if (!類 && 等 === '三' && 鈍音母.includes(母)) { 1143 | const [典型搭配類] = 類搭配(母, 韻); 1144 | if (典型搭配類.length === 1) { 1145 | 類 = 典型搭配類; 1146 | } 1147 | } 1148 | } 1149 | 1150 | // NOTE type assertion safe because the constructor checks it 1151 | return new 音韻地位(母, 呼, 等!, 類, 韻, 聲, 邊緣地位種類); 1152 | } 1153 | } 1154 | 1155 | /** 1156 | * 取得給定條件下可搭配的類,分為「不含邊緣地位」與「含邊緣地位」兩種。 1157 | * 用於 `音韻地位` 的 `.驗證`、`.from描述`、`.簡略描述`。 1158 | */ 1159 | function 類搭配(母: string, 韻: string): [string, string] { 1160 | let 搭配: [string, string] | null = null; 1161 | for (const [搭配類, 搭配韻] of [ 1162 | ['C', [...'東鍾之微魚虞廢殷元文歌尤嚴凡']], 1163 | ['AB', [...'支脂祭真仙宵麻幽侵鹽']], 1164 | ['A', [...'清']], 1165 | ['B', [...'庚']], 1166 | ['BC', [...'蒸']], 1167 | ['CA', [...'陽']], 1168 | ] as const) { 1169 | if (搭配韻.includes(韻)) { 1170 | 搭配 = [搭配類 === 'CA' ? 'C' : 搭配類, 搭配類]; 1171 | break; 1172 | } 1173 | } 1174 | if (搭配 === null) { 1175 | throw new Error(`unknown 韻: ${韻}`); 1176 | } 1177 | if (母 === '云') { 1178 | return 搭配.map(x => x.replace(/A/g, '')) as typeof 搭配; 1179 | } 1180 | return 搭配; 1181 | } 1182 | 1183 | /** 1184 | * 惰性求值參數,用於 `音韻地位.屬於` 標籤模板形式 1185 | */ 1186 | class LazyParameter { 1187 | private constructor( 1188 | private inner: unknown, 1189 | private 地位: 音韻地位, 1190 | ) {} 1191 | 1192 | static from(param: unknown, 地位: 音韻地位): LazyParameter | boolean { 1193 | switch (typeof param) { 1194 | case 'string': 1195 | return 地位.屬於(param); 1196 | case 'function': 1197 | return new LazyParameter(param, 地位); 1198 | default: 1199 | return !!param; 1200 | } 1201 | } 1202 | 1203 | eval(): boolean { 1204 | if (typeof this.inner === 'function') { 1205 | this.inner = this.inner.call(undefined); 1206 | if (typeof this.inner === 'string') { 1207 | this.inner = this.地位.屬於(this.inner); 1208 | } 1209 | } 1210 | return (this.inner = !!this.inner); 1211 | } 1212 | 1213 | toString(): string { 1214 | return String(this.inner); 1215 | } 1216 | } 1217 | -------------------------------------------------------------------------------- /src/lib/音韻屬性常量.ts: -------------------------------------------------------------------------------- 1 | /** 全部六要素之枚舉 */ 2 | export const 所有 = { 3 | 母: [...'幫滂並明端透定泥來知徹澄孃精清從心邪莊初崇生俟章昌常書船日見溪羣疑影曉匣云以'], 4 | 呼: [...'開合'], 5 | 等: [...'一二三四'], 6 | 類: [...'ABC'], 7 | 韻: [...'東冬鍾江支脂之微魚虞模齊祭泰佳皆夬灰咍廢真臻文殷元魂痕寒刪山先仙蕭宵肴豪歌麻陽唐庚耕清青蒸登尤侯幽侵覃談鹽添咸銜嚴凡'], 8 | 聲: [...'平上去入'], 9 | } as const; 10 | 11 | /** 幫見影組聲母,在三等分ABC類 */ 12 | export const 鈍音母 = [...'幫滂並明見溪羣疑影曉匣云'] as const; 13 | 14 | export const 陰聲韻 = [...'支脂之微魚虞模齊祭泰佳皆夬灰咍廢蕭宵肴豪歌麻侯尤幽'] as const; 15 | 16 | /** 依可搭配的等列出各韻 */ 17 | export const 等韻搭配 = { 18 | 一: [...'冬模泰灰咍魂痕寒豪唐登侯覃談'], 19 | 二: [...'江佳皆夬刪山肴耕咸銜'], 20 | 三: [...'鍾支脂之微魚虞祭廢真臻文殷元仙宵陽清蒸尤幽侵鹽嚴凡'], 21 | 四: [...'齊先蕭青添'], 22 | 一三: [...'東歌'], 23 | 二三: [...'麻庚'], 24 | } as const; 25 | 26 | /** 依可搭配的呼列出各韻 */ 27 | export const 呼韻搭配 = { 28 | 開合: [...'支脂微齊祭泰佳皆夬廢真元寒刪山先仙歌麻陽唐庚耕清青蒸登'], 29 | 開: [...'之魚咍臻殷痕蕭宵肴豪幽侵覃談鹽添咸銜嚴'], 30 | 合: [...'虞灰文魂凡'], 31 | 中立: [...'東冬鍾江模尤侯'], 32 | } as const; 33 | 34 | /** 依可搭配的等列出各母,包含邊緣搭配 */ 35 | export const 等母搭配 = { 36 | 一二三四: [...'幫滂並明來見溪羣疑影曉匣'], 37 | 二三: [...'知徹澄孃莊初崇生俟'], 38 | 一三四: [...'精清從心邪'], 39 | 三: [...'章昌常書船日云以'], 40 | 一二四: [...'端透定泥'], 41 | } as const; 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2020", 5 | "outDir": "build/esnext", 6 | "rootDir": "src", 7 | "moduleResolution": "node", 8 | "module": "esnext", 9 | //"declaration": true, 10 | "inlineSourceMap": true, 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 12 | "resolveJsonModule": true /* Include modules imported with .json extension. */, 13 | 14 | "strict": true /* Enable all strict type-checking options. */, 15 | 16 | /* Strict Type-Checking Options */ 17 | // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 18 | // "strictNullChecks": true /* Enable strict null checks. */, 19 | // "strictFunctionTypes": true /* Enable strict checking of function types. */, 20 | // "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 21 | // "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 22 | // "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 23 | 24 | /* Additional Checks */ 25 | "noUnusedLocals": true /* Report errors on unused locals. */, 26 | "noUnusedParameters": true /* Report errors on unused parameters. */, 27 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 28 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 29 | 30 | /* Debugging Options */ 31 | "traceResolution": false /* Report module resolution log messages. */, 32 | "listEmittedFiles": false /* Print names of generated files part of the compilation. */, 33 | "listFiles": false /* Print names of files part of the compilation. */, 34 | "pretty": true /* Stylize errors and messages using color and context. */, 35 | 36 | /* Experimental Options */ 37 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 38 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 39 | 40 | "lib": ["es2020"], 41 | "types": [], 42 | "typeRoots": ["node_modules/@types", "src/types"] 43 | }, 44 | "include": ["src/**/*.ts"], 45 | "exclude": ["**/*.spec.ts"], 46 | "compileOnSave": false 47 | } 48 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "outDir": "build/test", 5 | "module": "commonjs", 6 | "declaration": false, 7 | "types": ["node"] 8 | }, 9 | "exclude": [] 10 | } 11 | --------------------------------------------------------------------------------