├── demo ├── build.sh ├── overview.png └── overview.dot ├── .gitattributes ├── src ├── index.ts ├── TshetUinh.ts ├── lib │ ├── StringLogger.spec.ts │ ├── StringLogger.ts │ ├── 壓縮表示.spec.ts │ ├── 常用表達式.ts │ ├── 音韻屬性常量.ts │ ├── utils.ts │ ├── 拓展音韻屬性.ts │ ├── 壓縮表示.ts │ ├── 資料.spec.ts │ ├── 韻鏡.spec.ts │ ├── 反切.spec.ts │ ├── 資料.ts │ ├── 反切.ts │ ├── 音韻地位.spec.ts │ ├── 韻鏡.ts │ └── 音韻地位.ts └── data │ ├── 廣韻impl.ts │ ├── 廣韻.spec.ts │ └── 廣韻.ts ├── .vscode ├── settings.json ├── extensions.json └── launch.json ├── .prettierrc.json ├── tsconfig.test.json ├── .gitignore ├── inject_cf_analytics.sh ├── rollup.config.mjs ├── .github └── workflows │ ├── publish.yml │ ├── build.yml │ └── documentation.yml ├── README.md ├── LICENSE ├── tsconfig.json ├── eslint.config.mjs ├── package.json └── prepare └── main.py /demo/build.sh: -------------------------------------------------------------------------------- 1 | dot -Tpng demo/overview.dot > demo/overview.png 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /demo/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-js/HEAD/demo/overview.png -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TshetUinh'; 2 | 3 | import * as TshetUinh from './TshetUinh'; 4 | export default TshetUinh; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/韻鏡(古逸叢書本).csv 19 | /prepare/王三反切音韻地位表.csv 20 | /prepare/v2.csv 21 | 22 | /src/data/raw/ 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 10 | export { 韻鏡位置, 音韻地位2韻鏡位置 } from './lib/韻鏡'; 11 | 12 | export { 執行反切 } from './lib/反切'; 13 | 14 | export { StringLogger, defaultLogger } from './lib/StringLogger'; 15 | -------------------------------------------------------------------------------- /src/lib/StringLogger.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { defaultLogger } from './StringLogger'; 4 | 5 | test('測試 StringLogger', t => { 6 | defaultLogger.enable = true; 7 | defaultLogger.log('測試 1'); 8 | defaultLogger.log('測試 2'); 9 | t.deepEqual(defaultLogger.popAll(), ['測試 1', '測試 2']); 10 | t.is(defaultLogger.popAll().length, 0); 11 | defaultLogger.enable = false; 12 | defaultLogger.log('這條不應該被記錄'); 13 | t.is(defaultLogger.popAll().length, 0); 14 | }); 15 | -------------------------------------------------------------------------------- /src/lib/StringLogger.ts: -------------------------------------------------------------------------------- 1 | export class StringLogger { 2 | private res: string[]; 3 | public enable: boolean; 4 | 5 | constructor(enable = false) { 6 | this.res = []; 7 | this.enable = enable; 8 | } 9 | 10 | log(str: string): void { 11 | if (!this.enable) return; 12 | this.res.push(str); 13 | } 14 | 15 | popAll(): string[] { 16 | const res = [...this.res]; 17 | this.res.length = 0; 18 | return res; 19 | } 20 | } 21 | 22 | export const defaultLogger = new StringLogger(); 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | "experimentalDecorators": true, 14 | 15 | "strict": true /* Enable all strict type-checking options. */, 16 | 17 | /* Strict Type-Checking Options */ 18 | // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 19 | // "strictNullChecks": true /* Enable strict null checks. */, 20 | // "strictFunctionTypes": true /* Enable strict checking of function types. */, 21 | // "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 22 | // "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 23 | // "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 24 | 25 | /* Additional Checks */ 26 | "noUnusedLocals": true /* Report errors on unused locals. */, 27 | "noUnusedParameters": true /* Report errors on unused parameters. */, 28 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 29 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 30 | 31 | /* Debugging Options */ 32 | "traceResolution": false /* Report module resolution log messages. */, 33 | "listEmittedFiles": false /* Print names of generated files part of the compilation. */, 34 | "listFiles": false /* Print names of files part of the compilation. */, 35 | "pretty": true /* Stylize errors and messages using color and context. */, 36 | 37 | /* Experimental Options */ 38 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 39 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 40 | 41 | "lib": ["es2020"], 42 | "types": [], 43 | "typeRoots": ["node_modules/@types", "src/types"] 44 | }, 45 | "include": ["src/**/*.ts"], 46 | "exclude": ["**/*.spec.ts"], 47 | "compileOnSave": false 48 | } 49 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/資料.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/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/lib/韻鏡.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { iter音韻地位 } from './資料'; 4 | import { 音韻地位 } from './音韻地位'; 5 | import { 音韻地位2韻鏡位置, 韻鏡位置 } from './韻鏡'; 6 | 7 | test('音韻地位與韻鏡位置可以互相轉換', t => { 8 | const skipList = [ 9 | '日開三祭平', // 臡,特殊字無法處理(祭、平) 10 | '常開三祭平', // 栘,特殊字無法處理(祭、平) 11 | '溪開三B蒸平', // 硱,特殊字無法處理(蒸、!(幫組或合口)、B) 12 | '曉開三B幽平', // 烋,與「曉開三A幽平」無法區分 13 | '生開三鹽平', // 襳,韻鏡無法表示(鹽韻、韻鏡二等) 14 | '定開二佳上', // 箉,特殊字無法處理(定、二) 15 | '云合三C廢上', // 倄,特殊字無法處理(廢、上) 16 | '昌開三廢上', // 茝,特殊字無法處理(廢、上) 17 | '以開三廢上', // 佁,特殊字無法處理(廢、上) 18 | '明三A麻上', // 乜,特殊字無法處理(麻、A) 19 | '並三A陽上', // 𩦠,特殊字無法處理(陽、A) 20 | '端開二庚上', // 打,與「知開二庚上」無法區分 21 | '生合三祭去', // 𠻜,韻鏡無法表示(祭韻、韻鏡二等) 22 | '初合三祭去', // 㯔,韻鏡無法表示(祭韻、韻鏡二等) 23 | '生開三祭去', // 㡜,韻鏡無法表示(祭韻、韻鏡二等) 24 | '初合三元去', // 𣀔,韻鏡無法表示(元韻、韻鏡二等) 25 | '影開三B蒸入', // 抑,與「影開三C蒸入」無法區分 26 | '生開三鹽入', // 萐,韻鏡無法表示(鹽韻、韻鏡二等) 27 | '以開三嚴入', // 殜,韻鏡無法表示(嚴韻、韻鏡四等) 28 | // 莊組仙韻在下方處理,故此處無需處理 29 | // '崇開三仙平', // 潺,與「崇開二山平」(亦「潺」字)無法區分 30 | // '莊合三仙平', // 恮 31 | // '生合三仙平', // 栓 32 | // '崇合三仙上', // 撰,與「崇合二山上」(亦「撰」字)無法區分 33 | // '崇開三仙上', // 棧,與「崇開二山上」(亦「棧」字)無法區分 34 | // '生合三仙去', // 𨏉 35 | // '崇合三仙去', // 䉵 36 | // '莊合三仙去', // 孨 37 | // '生合三仙入', // 㕞 38 | // '莊合三仙入', // 茁 39 | // '生開三仙入', // 榝 40 | // '初合三仙入', // 㔍 41 | // '崇開三仙入', // 𨵊 42 | ]; 43 | 44 | for (let 當前音韻地位 of iter音韻地位()) { 45 | if (skipList.includes(當前音韻地位.描述)) { 46 | continue; 47 | } 48 | 49 | let 當前韻鏡位置: 韻鏡位置; 50 | let recovered音韻地位: 音韻地位; 51 | 52 | try { 53 | 當前韻鏡位置 = 音韻地位2韻鏡位置(當前音韻地位); 54 | } catch (e) { 55 | console.error(`Error when processing 音韻地位 ${當前音韻地位.描述}`); 56 | throw e; 57 | } 58 | 59 | try { 60 | recovered音韻地位 = 當前韻鏡位置.to音韻地位(); 61 | } catch (e) { 62 | console.error(`Error when processing 音韻地位 ${當前音韻地位.描述} 韻鏡位置 ${當前韻鏡位置.坐標}`); 63 | throw e; 64 | } 65 | 66 | // 莊組仙韻特殊處理 67 | if (當前音韻地位.屬於('莊組 仙韻')) { 68 | const 山or刪 = 當前音韻地位.屬於('入聲') ? '刪' : '山'; 69 | 當前音韻地位 = 當前音韻地位.調整(`${山or刪}韻 二等`); // 韻鏡無法區分 70 | } 71 | 72 | t.true( 73 | recovered音韻地位.等於(當前音韻地位), 74 | `音韻地位 ${recovered音韻地位.描述} recovered from ${當前韻鏡位置.坐標} does not equal to the original 音韻地位 ${當前音韻地位.描述}`, 75 | ); 76 | } 77 | }); 78 | 79 | test('測試基本反切', t => { 80 | const 反切上字音韻地位 = 音韻地位.from描述('端開一登入'); // 德 81 | const 反切下字音韻地位 = 音韻地位.from描述('匣一東平'); // 紅 82 | const 被切字音韻地位 = 音韻地位.from描述('端一東平'); // 東 83 | 84 | const 反切上字韻鏡位置 = 音韻地位2韻鏡位置(反切上字音韻地位); 85 | const 反切下字韻鏡位置 = 音韻地位2韻鏡位置(反切下字音韻地位); 86 | 87 | // 橫推直看 88 | const { 右位: 上字右位 } = 反切上字韻鏡位置; 89 | const { 轉號: 下字轉號, 上位: 下字上位 } = 反切下字韻鏡位置; 90 | const computed被切字韻鏡位置 = new 韻鏡位置(下字轉號, 下字上位, 上字右位); 91 | const computed被切字音韻地位 = computed被切字韻鏡位置.to音韻地位(); 92 | 93 | t.true( 94 | 被切字音韻地位.等於(computed被切字音韻地位), 95 | `被切字音韻地位應該等於計算出的被切字音韻地位, but the original is ${被切字音韻地位.描述} and the computed is ${computed被切字音韻地位.描述}`, 96 | ); 97 | }); 98 | -------------------------------------------------------------------------------- /src/lib/反切.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | 3 | import test from 'ava'; 4 | 5 | import { defaultLogger } from './StringLogger'; 6 | import { 執行反切 } from './反切'; 7 | import { 音韻地位 } from './音韻地位'; 8 | 9 | test('可以正常執行反切', t => { 10 | let rightCountHasEqual = 0; 11 | let rightCountExactEqual = 0; 12 | let totalCount = 0; 13 | 14 | const data = readFileSync('prepare/王三反切音韻地位表.csv', { encoding: 'utf8' }) 15 | .trimEnd() 16 | .split('\n') 17 | .slice(1) 18 | .map(line => line.split(',')); 19 | 20 | for (const [ 21 | , 22 | , 23 | , 24 | , 25 | , 26 | , 27 | , 28 | , 29 | , 30 | , 31 | 首字校後, 32 | 上字校後, 33 | 下字校後, 34 | 被切字聲母, 35 | 被切字呼, 36 | 被切字等, 37 | 被切字類, 38 | 被切字韻, 39 | 被切字聲調, 40 | 上字聲母, 41 | 上字呼, 42 | 上字等, 43 | 上字類, 44 | 上字韻, 45 | 上字聲調, 46 | 下字聲母, 47 | 下字呼, 48 | 下字等, 49 | 下字類, 50 | 下字韻, 51 | 下字聲調, 52 | 被切字切語相關地位不一致, 53 | , 54 | , 55 | , 56 | 音節合法性, 57 | ] of data) { 58 | if (!首字校後 || !上字校後 || !下字校後) continue; 59 | if (!被切字聲母 || !上字聲母 || !下字聲母) continue; 60 | if (音節合法性 === '強非法') continue; 61 | if (被切字切語相關地位不一致) continue; 62 | 63 | const 被切字音韻地位 = new 音韻地位(被切字聲母, 被切字呼 || null, 被切字等, 被切字類 || null, 被切字韻, 被切字聲調); 64 | const 上字音韻地位 = new 音韻地位(上字聲母, 上字呼 || null, 上字等, 上字類 || null, 上字韻, 上字聲調); 65 | const 下字音韻地位 = new 音韻地位(下字聲母, 下字呼 || null, 下字等, 下字類 || null, 下字韻, 下字聲調); 66 | 67 | const 預測音韻地位們 = 執行反切(上字音韻地位, 下字音韻地位); 68 | 69 | totalCount += 1; 70 | const hasEqual = 預測音韻地位們.some(預測音韻地位 => 預測音韻地位.等於(被切字音韻地位)); 71 | if (hasEqual) rightCountHasEqual += 1; 72 | const exactEqual = 預測音韻地位們.length === 1 && 預測音韻地位們[0].等於(被切字音韻地位); 73 | if (exactEqual) rightCountExactEqual += 1; 74 | } 75 | 76 | const accuracy = rightCountHasEqual / totalCount; 77 | // console.log(`反切的準確率(多個結果中至少有一個正確)為 ${accuracy * 100}%`); 78 | t.true(accuracy > 0.994, `反切的準確率(多個結果中至少有一個正確)必須大於 99.4%,實際為 ${accuracy * 100}%`); 79 | 80 | const accuracyExactEqual = rightCountExactEqual / totalCount; 81 | // console.log(`反切的準確率(只給出一個結果且正確)為 ${accuracyExactEqual * 100}%`); 82 | t.true(accuracyExactEqual > 0.851, `反切的準確率(只給出一個結果且正確)必須大於 85.1%,實際為 ${accuracyExactEqual * 100}%`); 83 | }); 84 | 85 | test('可以為反切結果給出解釋', t => { 86 | const data = ['東', '德', '紅', '端一東平', '端開一登入', '匣一東平']; 87 | const [, , , 被切字音韻描述, 上字音韻描述, 下字音韻描述] = data; 88 | 89 | const 被切字音韻地位 = 音韻地位.from描述(被切字音韻描述); 90 | const 上字音韻地位 = 音韻地位.from描述(上字音韻描述); 91 | const 下字音韻地位 = 音韻地位.from描述(下字音韻描述); 92 | 93 | defaultLogger.enable = true; 94 | const 預測音韻地位們 = 執行反切(上字音韻地位, 下字音韻地位); 95 | const 解釋 = defaultLogger.popAll(); 96 | // console.log(解釋); 97 | defaultLogger.enable = false; 98 | 99 | const hasEqual = 預測音韻地位們.some(預測音韻地位 => 預測音韻地位.等於(被切字音韻地位)); 100 | t.true(hasEqual, '可以正常反切'); 101 | t.true( 102 | 解釋[0] === '反切上字為端母,故被切字為端母' && 解釋[1] === '反切下字為東韻平聲,故被切字為東韻平聲', 103 | '可以正常為反切結果給出解釋', 104 | ); 105 | }); 106 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tshet-uinh", 3 | "version": "0.15.3", 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.5.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 | "dependencies": { 111 | "decorator-cache-getter": "^1.0.0" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /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 | if not os.path.exists('prepare/韻鏡(古逸叢書本).csv'): 83 | status = os.system( 84 | 'curl -LsSo prepare/韻鏡(古逸叢書本).csv https://raw.githubusercontent.com/nk2028/tshet-uinh-data/ccc9325/%E9%9F%BB%E5%9C%96/%E9%9F%BB%E9%8F%A1%EF%BC%88%E5%8F%A4%E9%80%B8%E5%8F%A2%E6%9B%B8%E6%9C%AC%EF%BC%89.csv' 85 | ) 86 | assert status == 0, f'Error: curl exited with status code {status}' 87 | 88 | if not os.path.exists('prepare/王三反切音韻地位表.csv'): 89 | status = os.system( 90 | 'curl -LsSo prepare/王三反切音韻地位表.csv https://raw.githubusercontent.com/nk2028/tshet-uinh-data/ccc9325/%E5%8F%8D%E5%88%87%E9%9F%B3%E9%9F%BB%E5%9C%B0%E4%BD%8D/%E7%8E%8B%E4%B8%89%E5%8F%8D%E5%88%87%E9%9F%B3%E9%9F%BB%E5%9C%B0%E4%BD%8D%E8%A1%A8.csv' 91 | ) 92 | assert status == 0, f'Error: curl exited with status code {status}' 93 | 94 | 韻目原貌by原書小韻: dict[int, str] = {} 95 | 原書小韻音韻: dict[int, dict[str, tuple[str, str]]] = {} 96 | 原書小韻內容: dict[int, list[tuple[str, str, str]]] = {} 97 | with open('prepare/data.csv') as fin: 98 | next(fin) 99 | max原書小韻號: int = 0 100 | cur音韻: dict[str, tuple[str, str]] = None 101 | cur內容: list[tuple[str, str, str]] = None 102 | for row in csv.reader(fin): 103 | ( 104 | 小韻號, 105 | _, 106 | 韻目原貌, 107 | 音韻地位描述, 108 | 反切, 109 | 字頭, 110 | 釋義, 111 | 釋義補充, 112 | ) = row 113 | 114 | if 小韻號[-1].isalpha(): 115 | 原書小韻號, 小韻細分 = int(小韻號[:-1]), 小韻號[-1] 116 | else: 117 | 原書小韻號, 小韻細分 = int(小韻號), '' 118 | 119 | if 原書小韻號 != max原書小韻號: 120 | assert 原書小韻號 == max原書小韻號 + 1 121 | max原書小韻號 = 原書小韻號 122 | 韻目原貌by原書小韻[原書小韻號] = 韻目原貌 123 | 原書小韻音韻[原書小韻號] = cur音韻 = {} 124 | 原書小韻內容[原書小韻號] = cur內容 = [] 125 | 126 | assert 韻目原貌 == 韻目原貌by原書小韻[原書小韻號] 127 | 128 | 音韻編碼 = 編碼_from_描述(音韻地位描述) if 音韻地位描述 else '@@@' 129 | if 小韻細分 in cur音韻: 130 | assert cur音韻[小韻細分] == (音韻編碼, 反切) 131 | else: 132 | cur音韻[小韻細分] = (音韻編碼, 反切) 133 | 134 | cur內容.append((字頭, 小韻細分, 釋義 + (釋義補充 and f'({釋義補充})'))) 135 | 136 | for 原書小韻號, 各音韻信息 in 原書小韻音韻.items(): 137 | 各細分 = tuple(各音韻信息.keys()) 138 | if len(各細分) == 1: 139 | assert 各細分[0] == '' 140 | else: 141 | assert 1 < len(各細分) <= 26 142 | assert 各細分 == tuple(chr(ord('a') + i) for i in range(len(各細分))) 143 | 144 | os.makedirs('src/data/raw', exist_ok=True) 145 | with open('src/data/raw/廣韻.ts', 'w', newline='') as fout: 146 | print('export default `\\', file=fout) 147 | cur韻目 = None 148 | for 原書小韻號 in range(1, max原書小韻號 + 1): 149 | 韻目 = 韻目原貌by原書小韻[原書小韻號] 150 | if 韻目 != cur韻目: 151 | print(f'#{韻目}', file=fout) 152 | cur韻目 = 韻目 153 | print( 154 | ''.join( 155 | 音韻編碼 + (反切 or '@@') 156 | for 音韻編碼, 反切 in 原書小韻音韻[原書小韻號].values() 157 | ), 158 | '|'.join( 159 | 字頭 + 小韻細分 + 釋義 160 | for 字頭, 小韻細分, 釋義 in 原書小韻內容[原書小韻號] 161 | ), 162 | sep='', 163 | file=fout, 164 | ) 165 | print('` as string;', file=fout) 166 | 167 | 168 | if __name__ == '__main__': 169 | if len(sys.argv) == 2 and sys.argv[1] == 'test': 170 | list_地位編碼() 171 | else: 172 | main() 173 | -------------------------------------------------------------------------------- /src/lib/反切.ts: -------------------------------------------------------------------------------- 1 | import { defaultLogger } from './StringLogger'; 2 | import { 音韻地位 } from './音韻地位'; 3 | import { 呼韻搭配, 等母搭配, 等韻搭配, 鈍音母 } from './音韻屬性常量'; 4 | 5 | const 重紐韻 = [...'支脂祭真仙宵清侵鹽']; 6 | 7 | // 編寫反切規則參考了潘悟雲《反切行為與反切原則》 8 | 9 | const generate呼 = (母: string, 組: string | null, 韻: string, 上字呼: string | null, 下字呼: string | null, 下字組: string | null) => { 10 | let 呼; 11 | if (組 === '幫' || 呼韻搭配.中立.includes(韻)) { 12 | 呼 = null; 13 | } else if (呼韻搭配.開.includes(韻)) { 14 | 呼 = '開'; 15 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為開口,故被切字為開口`); 16 | } else if (呼韻搭配.合.includes(韻)) { 17 | 呼 = '合'; 18 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為合口,故被切字為合口`); 19 | } else if (母 === '云') { 20 | 呼 = '合'; 21 | defaultLogger.log('被切字為云母,云母為合口,故被切字為合口'); 22 | } else { 23 | if (上字呼 === '開' && 下字呼 === '開') { 24 | 呼 = '開'; 25 | defaultLogger.log('反切上下字均為開口,故被切字為開口'); 26 | } else if (下字呼 === '合') { 27 | 呼 = '合'; 28 | defaultLogger.log('反切下字為合口,故被切字為合口'); 29 | } else if (上字呼 === '合' && 下字組 === '幫') { 30 | 呼 = '合'; 31 | defaultLogger.log('反切上字為合口,下字為幫組,故被切字為合口'); 32 | } else { 33 | 呼 = '開合'; 34 | defaultLogger.log('無法確定被切字的呼,可能為開口或合口'); 35 | } 36 | } 37 | return 呼 === '開合' ? [...呼] : [呼]; 38 | }; 39 | 40 | const generate等 = (母: string, 韻: string, 上字等: string, 下字等: string) => { 41 | let 等; 42 | if (等韻搭配.一.includes(韻)) { 43 | 等 = '一'; 44 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為一等,故被切字為一等`); 45 | } else if (等韻搭配.二.includes(韻)) { 46 | 等 = '二'; 47 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為二等,故被切字為二等`); 48 | } else if (等韻搭配.三.includes(韻)) { 49 | 等 = '三'; 50 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為三等,故被切字為三等`); 51 | } else if (等韻搭配.四.includes(韻)) { 52 | 等 = '四'; 53 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為四等,故被切字為四等`); 54 | } else if (下字等 === '三') { 55 | 等 = '三'; 56 | defaultLogger.log('反切下字為三等,故被切字為三等'); 57 | } else if (上字等 !== '三' && 下字等 !== '三') { 58 | defaultLogger.log('反切上下字均非三等,故被切字非三等'); 59 | if (等韻搭配.一三.includes(韻)) { 60 | 等 = '一'; 61 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為一等或三等,而被切字非三等,故被切字為一等`); 62 | } else if (等韻搭配.二三.includes(韻)) { 63 | 等 = '二'; 64 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為二等或三等,而被切字非三等,故被切字為二等`); 65 | } else { 66 | throw Error('Unreachable'); 67 | } 68 | } else { 69 | if (等韻搭配.一三.includes(韻)) { 70 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為一等或三等,故被切字為一等或三等`); 71 | if (等母搭配.二三.includes(母) || 等母搭配.三.includes(母)) { 72 | 等 = '三'; 73 | defaultLogger.log(`被切字為${母}母,${母}母不可能為一等,故被切字為三等`); 74 | } else if (等母搭配.一二四.includes(母)) { 75 | 等 = '一'; 76 | defaultLogger.log(`被切字為${母}母,${母}母不可能為三等,故被切字為一等`); 77 | } else { 78 | 等 = '一三'; 79 | defaultLogger.log('無法確定被切字的等,可能為一等或三等'); 80 | } 81 | } else if (等韻搭配.二三.includes(韻)) { 82 | defaultLogger.log(`被切字為${韻}韻,${韻}韻為二等或三等,故被切字為二等或三等`); 83 | if (等母搭配.一三四.includes(母) || 等母搭配.三.includes(母)) { 84 | 等 = '三'; 85 | defaultLogger.log(`被切字為${母}母,${母}母不可能為二等,故被切字為三等`); 86 | } else if (等母搭配.一二四.includes(母)) { 87 | 等 = '二'; 88 | defaultLogger.log(`被切字為${母}母,${母}母不可能為三等,故被切字為二等`); 89 | } else { 90 | 等 = '二三'; 91 | defaultLogger.log('無法確定被切字的等,可能為二等或三等'); 92 | } 93 | } else { 94 | throw Error('Unreachable'); 95 | } 96 | } 97 | return 等 === '一三' || 等 === '二三' ? [...等] : [等]; 98 | }; 99 | 100 | // 類需特殊處理,故寫法與上述兩函式不同 101 | const rawGenerate類 = ( 102 | 下字音韻地位: 音韻地位, 103 | 母: string, 104 | 組: string | null, 105 | 韻: string, 106 | 上字類: string | null, 107 | 呼: string | null, 108 | 等: string, 109 | ): { 類: string | null; 解釋: string | null } => { 110 | if (等 !== '三' || !鈍音母.includes(母)) { 111 | return { 類: null, 解釋: null }; 112 | } else if (韻 === '幽') { 113 | if (組 === '幫') { 114 | return { 類: 'B', 解釋: '被切字為幽韻,且為幫組,故被切字為 B 類' }; // 幫組、「惆」、「烋」爲 B 類 115 | } else { 116 | return { 類: 'A', 解釋: '被切字為幽韻,且非幫組,故被切字為 A 類' }; 117 | } 118 | } else if (韻 === '蒸') { 119 | if (組 === '幫' || 呼 === '合') { 120 | return { 類: 'B', 解釋: '被切字為蒸韻,且為幫組或合口,故被切字為 B 類' }; // 幫組、合口、「抑」爲 B 類 121 | } else { 122 | return { 類: 'C', 解釋: '被切字為蒸韻,且非幫組或合口,故被切字為 C 類' }; 123 | } 124 | } else if (韻 === '庚') { 125 | return { 類: 'B', 解釋: '被切字為庚韻,故被切字為 B 類' }; 126 | } else if (!重紐韻.includes(韻)) { 127 | return { 類: 'C', 解釋: '被切字非重紐韻,故被切字為 C 類' }; // TODO: confirm this 128 | } else if (母 === '云') { 129 | return { 類: 'B', 解釋: '被切字為云母,故被切字為 B 類' }; 130 | } else { 131 | if (上字類 === 'A') { 132 | return { 類: 'A', 解釋: '反切上字為 A 類,故被切字為 A 類' }; 133 | } else if (上字類 === 'B') { 134 | return { 類: 'B', 解釋: '反切上字為 B 類,故被切字為 B 類' }; 135 | } else if (下字音韻地位.屬於('A類 或 以母 或 精組')) { 136 | return { 類: 'A', 解釋: '反切下字為 A 類、以母或精組,故被切字為 A 類' }; 137 | } else if (下字音韻地位.屬於('B類 或 云母')) { 138 | return { 類: 'B', 解釋: '反切下字為 B 類或云母,故被切字為 B 類' }; 139 | } else { 140 | return { 類: 'AB', 解釋: '無法確定被切字的類,可能為 A 類或 B 類' }; 141 | } 142 | } 143 | }; 144 | 145 | export const 執行反切 = (上字音韻地位: 音韻地位, 下字音韻地位: 音韻地位): 音韻地位[] => { 146 | const { 母, 組, 呼: 上字呼, 等: 上字等, 類: 上字類 } = 上字音韻地位; 147 | defaultLogger.log(`反切上字為${母}母,故被切字為${母}母`); 148 | 149 | const { 韻, 聲, 呼: 下字呼, 組: 下字組, 等: 下字等 } = 下字音韻地位; 150 | defaultLogger.log(`反切下字為${韻}韻${聲}聲,故被切字為${韻}韻${聲}聲`); 151 | 152 | const 所有呼 = generate呼(母, 組, 韻, 上字呼, 下字呼, 下字組); 153 | const 所有等 = generate等(母, 韻, 上字等, 下字等); 154 | 155 | // 在特定呼、特定等的條件下處理類 156 | const res: 音韻地位[] = []; 157 | 158 | const 條件_解釋: { 條件: string; 解釋: string | null }[] = []; 159 | const 忽略: string[] = []; 160 | 161 | for (const 呼 of 所有呼) { 162 | for (const 等 of 所有等) { 163 | const 條件 = 164 | 所有呼.length > 1 || 所有等.length > 1 165 | ? `當呼為${呼}口、等為${等}等時,` 166 | : 所有呼.length > 1 167 | ? `當呼為${呼}口時,` 168 | : 所有等.length > 1 169 | ? `當等為${等}等時,` 170 | : ''; 171 | const { 類, 解釋 } = rawGenerate類(下字音韻地位, 母, 組, 韻, 上字類, 呼, 等); 172 | 條件_解釋.push({ 條件, 解釋 }); 173 | for (const 類_ of 類 === 'AB' ? ['A', 'B'] : [類]) { 174 | try { 175 | res.push(new 音韻地位(母, 呼, 等, 類_, 韻, 聲)); 176 | } catch (e) { 177 | const msg = e instanceof Error ? e.message : String(e); 178 | 忽略.push(`忽略無效的音韻地位「${母}${呼 ?? ''}${等}${類 ?? ''}${韻}${聲}」,原因:${msg}`); 179 | } 180 | } 181 | } 182 | } 183 | 184 | if (條件_解釋.length > 0) { 185 | // 如果所有解釋均相同,則只需輸出一次解釋,無需輸出條件 186 | if (條件_解釋.every(({ 解釋 }) => 解釋 === 條件_解釋[0].解釋)) { 187 | const { 解釋 } = 條件_解釋[0]; 188 | if (解釋 !== null) { 189 | defaultLogger.log(解釋); 190 | } 191 | } 192 | // 如果解釋不全相同,則對每個條件,都輸出對應的解釋 193 | else { 194 | for (const { 條件, 解釋 } of 條件_解釋) { 195 | if (解釋 !== null) { 196 | defaultLogger.log(`${條件}${解釋}`); 197 | } 198 | } 199 | } 200 | } 201 | 202 | for (const msg of 忽略) { 203 | defaultLogger.log(msg); 204 | } 205 | 206 | return res; 207 | }; 208 | -------------------------------------------------------------------------------- /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 { cache } from 'decorator-cache-getter'; 2 | 3 | import { defaultLogger } from './StringLogger'; 4 | import { 音韻地位 } from './音韻地位'; 5 | import { 等韻搭配, 鈍音母 } from './音韻屬性常量'; 6 | 7 | const 轉呼 = [null, null, null, ...'開合開合開開合開合開合開合開合開合開合開合開開開合開合開合開合開合開開開開合開合'] as const; 8 | const 母2idx = [...'幫滂並明端透定泥知徹澄孃見溪羣疑精清從心邪章昌船書常莊初崇生俟影曉匣云以來日'] as const; 9 | const 母idx2右位 = [ 10 | 1, 2, 3, 4, 5, 6, 7, 8, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 13, 14, 15, 16, 17, 13, 14, 15, 16, 17, 18, 19, 20, 21, 21, 22, 23, 11 | ] as const; 12 | const 轉名稱列表 = [ 13 | '內轉第一', 14 | '內轉第二', 15 | '外轉第三', 16 | '內轉第四', 17 | '內轉第五', 18 | '內轉第六', 19 | '內轉第七', 20 | '內轉第八', 21 | '內轉第九', 22 | '內轉第十', 23 | '內轉第十一', 24 | '內轉第十二', 25 | '內轉第十三', 26 | '外轉第十四', 27 | '外轉第十五', 28 | '外轉第十六', 29 | '外轉第十七', 30 | '外轉第十八', 31 | '外轉第十九', 32 | '外轉第二十', 33 | '外轉第二十一', 34 | '外轉第二十二', 35 | '外轉第二十三', 36 | '外轉第二十四', 37 | '外轉第二十五', 38 | '外轉第二十六', 39 | '內轉第二十七', 40 | '內轉第二十八', 41 | '內轉第二十九', 42 | '外轉第三十', 43 | '內轉第三十一', 44 | '內轉第三十二', 45 | '外轉第三十三', 46 | '外轉第三十四', 47 | '外轉第三十五', 48 | '外轉第三十六', 49 | '內轉第三十七', 50 | '內轉第三十八', 51 | '外轉第三十九', 52 | '外轉第四十', 53 | '外轉第四十一', 54 | '外轉第四十二', 55 | '外轉第四十三', 56 | ] as const; 57 | const 母位置名稱 = [ 58 | null, 59 | '脣音第一位', 60 | '脣音第二位', 61 | '脣音第三位', 62 | '脣音第四位', 63 | '舌音第一位', 64 | '舌音第二位', 65 | '舌音第三位', 66 | '舌音第四位', 67 | '牙音第一位', 68 | '牙音第二位', 69 | '牙音第三位', 70 | '牙音第四位', 71 | '齒音第一位', 72 | '齒音第二位', 73 | '齒音第三位', 74 | '齒音第四位', 75 | '齒音第五位', 76 | '喉音第一位', 77 | '喉音第二位', 78 | '喉音第三位', 79 | '喉音第四位', 80 | '舌齒音第一位', 81 | '舌齒音第二位', 82 | ] as const; 83 | 84 | export class 韻鏡位置 { 85 | 轉號: number; 86 | 上位: number; 87 | 右位: number; 88 | 89 | constructor(轉號: number, 上位: number, 右位: number) { 90 | if (轉號 < 1 || 轉號 > 43) { 91 | throw new Error('轉號必須在 1 到 43 之間'); 92 | } 93 | if (上位 < 1 || 上位 > 16) { 94 | throw new Error('上位必須在 1 到 16 之間'); 95 | } 96 | if (右位 < 1 || 右位 > 23) { 97 | throw new Error('右位必須在 1 到 23 之間'); 98 | } 99 | this.轉號 = 轉號; 100 | this.上位 = 上位; 101 | this.右位 = 右位; 102 | } 103 | 104 | @cache 105 | get 轉名稱() { 106 | return `${轉名稱列表[this.轉號 - 1]}圖`; 107 | } 108 | 109 | @cache 110 | get 坐標() { 111 | const { 轉號, 上位, 右位 } = this; 112 | return `(${轉號},${上位},${右位})`; 113 | } 114 | 115 | @cache 116 | get 韻鏡等() { 117 | const { 上位 } = this; 118 | const 韻鏡等 = ((上位 - 1) % 4) + 1; 119 | return 韻鏡等; 120 | } 121 | 122 | @cache 123 | get 韻() { 124 | const { 轉號, 上位, 右位 } = this; 125 | return 轉號上位右位2韻(轉號, 上位, 右位); 126 | } 127 | 128 | @cache 129 | get 切韻等() { 130 | const { 轉號, 上位, 右位, 韻鏡等, 韻 } = this; 131 | 132 | if (轉號 === 6 && 上位 === 12 && 右位 === 7) { 133 | defaultLogger.log(`韻鏡地位 ${this.坐標}(即「地」字)為特殊情況,對應切韻四等`); 134 | return '四'; // 「地」字為真四等 135 | } 136 | if (轉號 === 29 && 上位 === 4 && 右位 === 5) { 137 | defaultLogger.log(`韻鏡地位 ${this.坐標}(即「爹」字)為特殊情況,對應切韻四等`); 138 | return '四'; // 「爹」字為真四等 139 | } 140 | if (韻鏡等 === 4 && !等韻搭配.四.includes(韻)) { 141 | defaultLogger.log(`韻鏡四等本應對應切韻四等,但${韻}韻非四等韻,故為假四等真三等,實際為切韻三等`); 142 | return '三'; // 假四等真三等 143 | } 144 | if (韻鏡等 === 2 && ![...等韻搭配.二, ...等韻搭配.二三].includes(韻)) { 145 | if (12 < 右位 && 右位 <= 17) { 146 | defaultLogger.log(`韻鏡二等本應對應切韻二等,但${韻}韻非二等韻,故為假二等真三等,實際為切韻三等`); 147 | return '三'; // 限定為齒音,假二等真三等 148 | } 149 | throw new Error('假二等真三等必須為齒音'); 150 | } 151 | if (轉號 === 33 && ((右位 === 16 && 韻鏡等 === 2) || (右位 === 14 && (上位 === 10 || 上位 === 14)))) { 152 | defaultLogger.log(`韻鏡二等本應對應切韻二等,但「生」、「省」、「索」、「㵾」、「柵」為特殊情況,屬於莊三化二,故實際為切韻三等`); 153 | return '三'; // 「生」、「省」、「索」、「㵾」、「柵」莊三化二 154 | } 155 | 156 | const 韻鏡等漢字 = [...'一二三四'][韻鏡等 - 1]; 157 | const shouldAdd一般情況Str = 韻鏡等 === 2 || 韻鏡等 === 4; 158 | defaultLogger.log(`韻鏡${韻鏡等漢字}等對應切韻${韻鏡等漢字}等${shouldAdd一般情況Str ? '(一般情況)' : ''}`); 159 | return 韻鏡等漢字; 160 | } 161 | 162 | @cache 163 | get 母() { 164 | const { 右位, 韻鏡等, 切韻等 } = this; 165 | 166 | // 幫非組 167 | if (右位 <= 4) { 168 | const 母 = [...'幫滂並明'][右位 - 1]; 169 | defaultLogger.log(`${母位置名稱[右位]},對應${母}母`); 170 | return 母; 171 | } 172 | 173 | // 端知組 174 | if (右位 <= 8) { 175 | // TODO: is 切韻等 correct? can handle 蛭,17,4,15? 176 | if (切韻等 === '一' || 切韻等 === '四') { 177 | const 母 = [...'端透定泥'][右位 - 4 - 1]; 178 | defaultLogger.log(`${母位置名稱[右位]},且為切韻一四等,對應${母}母`); 179 | return 母; 180 | } 181 | 182 | const 母 = [...'知徹澄孃'][右位 - 4 - 1]; 183 | defaultLogger.log(`${母位置名稱[右位]},且為切韻二三等,對應${母}母`); 184 | return 母; 185 | } 186 | 187 | // 見組 188 | if (右位 <= 12) { 189 | const 母 = [...'見溪羣疑'][右位 - 8 - 1]; 190 | defaultLogger.log(`${母位置名稱[右位]},對應${母}母`); 191 | return 母; 192 | } 193 | 194 | // 齒音 195 | if (右位 <= 17) { 196 | if (韻鏡等 === 1 || 韻鏡等 === 4) { 197 | const 母 = [...'精清從心邪'][右位 - 12 - 1]; 198 | defaultLogger.log(`${母位置名稱[右位]},且為韻鏡一四等,對應${母}母`); 199 | return 母; 200 | } 201 | if (韻鏡等 === 3) { 202 | const 母 = [...'章昌船書常'][右位 - 12 - 1]; // TODO: 常船位置 203 | defaultLogger.log(`${母位置名稱[右位]},且為韻鏡三等,對應${母}母`); 204 | return 母; 205 | } 206 | if (韻鏡等 === 2) { 207 | const 母 = [...'莊初崇生俟'][右位 - 12 - 1]; 208 | defaultLogger.log(`${母位置名稱[右位]},且為韻鏡二等,對應${母}母`); 209 | return 母; 210 | } 211 | throw new Error('invalid 韻鏡等'); 212 | } 213 | 214 | // 喉音 215 | if (右位 <= 20) { 216 | const 母 = [...'影曉匣'][右位 - 17 - 1]; 217 | defaultLogger.log(`${母位置名稱[右位]},對應${母}母`); 218 | return 母; 219 | } 220 | 221 | // 喻母 222 | if (右位 === 21) { 223 | if (韻鏡等 === 3) { 224 | defaultLogger.log(`${母位置名稱[右位]},且為韻鏡三等,對應云母`); 225 | return '云'; 226 | } 227 | defaultLogger.log(`${母位置名稱[右位]},且非韻鏡三等,對應以母`); 228 | return '以'; 229 | } 230 | 231 | // 舌齒音 232 | if (右位 <= 23) { 233 | const 母 = [...'來日'][右位 - 21 - 1]; 234 | defaultLogger.log(`${母位置名稱[右位]},對應${母}母`); 235 | return 母; 236 | } 237 | 238 | throw new Error('invalid 右位'); 239 | } 240 | 241 | @cache 242 | get 呼() { 243 | const { 轉號, 轉名稱, 韻, 母 } = this; 244 | if ([...'幫滂並明'].includes(母) || [...'模侯尤'].includes(韻)) { 245 | return null; 246 | } 247 | const 呼 = 轉呼[轉號 - 1]; 248 | if (呼 !== null) { 249 | defaultLogger.log(`${轉名稱}對應的呼為${呼}口`); 250 | } 251 | return 呼; 252 | } 253 | 254 | @cache 255 | get 聲() { 256 | const { 轉號, 上位 } = this; 257 | const raw聲 = [...'平上去入'][Math.floor((上位 - 1) / 4)]; 258 | if ([9, 10, 13, 14].includes(轉號) && raw聲 == '入') { 259 | defaultLogger.log(`此位置處於入聲位,但第 ${轉號} 轉入聲標註「去聲寄此」,故實際為去聲`); 260 | return '去'; // 標註「去聲寄此」 261 | } 262 | defaultLogger.log(`此位置處於${raw聲}聲位,故為${raw聲}聲`); 263 | return raw聲; 264 | } 265 | 266 | @cache 267 | get 類() { 268 | const { 韻鏡等, 切韻等, 韻, 母 } = this; 269 | if (切韻等 !== '三' || !鈍音母.includes(母)) { 270 | return null; 271 | } 272 | if (韻 === '幽') { 273 | const { 轉號, 上位, 右位 } = this; 274 | if ([...'幫滂並明'].includes(母) || (轉號 === 37 && 上位 === 4 && 右位 === 10)) { 275 | defaultLogger.log(`幽韻幫組及「惆」字對應 B 類。注意「飍」、「烋」為 A、B 類對立,「烋」為 B 類,但此處無法區分二者`); 276 | return 'B'; // 幫組、「惆」為 B 類。注意「飍」、「烋」為 A、B 類對立,「烋」為 B 類,但此處無法區分二者 277 | } 278 | defaultLogger.log(`幽韻非幫組且非「惆」字對應 A 類`); 279 | return 'A'; 280 | } 281 | if (韻 === '蒸') { 282 | const { 呼 } = this; 283 | if ([...'幫滂並明'].includes(母) || 呼 === '合') { 284 | defaultLogger.log(`蒸韻幫組或合口對應 B 類。注意「憶」、「抑」為 B、C 類對立,「抑」為 B 類,但此處無法區分二者`); 285 | return 'B'; // 幫組、合口為 B 類。注意「憶」、「抑」為 B、C 類對立,「抑」為 B 類,但此處無法區分二者 286 | } 287 | defaultLogger.log(`蒸韻非幫組且非合口對應 C 類`); 288 | return 'C'; 289 | } 290 | if (![...'支脂祭真仙宵清侵鹽庚幽'].includes(韻)) { 291 | defaultLogger.log(`${韻}韻對應 C 類`); 292 | return 'C'; 293 | } 294 | if (韻鏡等 === 4) { 295 | defaultLogger.log(`韻鏡四等對應 A 類(一般情況)`); 296 | return 'A'; 297 | } 298 | if (韻鏡等 === 3) { 299 | defaultLogger.log(`韻鏡三等對應 B 類(一般情況)`); 300 | return 'B'; 301 | } 302 | throw new Error('error'); 303 | } 304 | 305 | @cache 306 | get 描述() { 307 | const { 上位, 右位, 轉名稱, 韻鏡等 } = this; 308 | const raw聲 = [...'平上去入'][Math.floor((上位 - 1) / 4)]; 309 | const 韻鏡等漢字 = [...'一二三四'][韻鏡等 - 1]; 310 | return `${轉名稱}·${母位置名稱[右位]}·${raw聲}聲位·韻鏡${韻鏡等漢字}等`; 311 | } 312 | 313 | to音韻地位() { 314 | const { 母, 呼, 切韻等, 類, 韻, 聲 } = this; 315 | const 當前音韻地位 = new 音韻地位(母, 呼, 切韻等, 類, 韻, 聲); 316 | return 當前音韻地位; 317 | } 318 | 319 | 等於(other: 韻鏡位置) { 320 | return this.轉號 === other.轉號 && this.上位 === other.上位 && this.右位 === other.右位; 321 | } 322 | } 323 | 324 | // 右位: 為區分尤/幽韻 325 | const 轉號上位右位2韻 = (轉號: number, 上位: number, 右位: number) => { 326 | const raw聲 = [...'平上去入'][Math.floor((上位 - 1) / 4)]; 327 | const 韻鏡等 = ((上位 - 1) % 4) + 1; 328 | const is齒音 = 12 < 右位 && 右位 <= 17; 329 | const 轉名稱 = `${轉名稱列表[轉號 - 1]}圖`; 330 | 331 | switch (轉號) { 332 | case 1: 333 | defaultLogger.log('此位置屬於東韻'); 334 | return '東'; 335 | case 2: 336 | if (韻鏡等 === 1) { 337 | if (raw聲 === '上') { 338 | defaultLogger.log(`${轉名稱}、上聲、韻鏡一等未標註對應韻,實際為冬韻,與其餘三聲相同`); 339 | return '冬'; 340 | } 341 | defaultLogger.log('此位置屬於冬韻'); 342 | return '冬'; 343 | } 344 | defaultLogger.log('此位置屬於鍾韻'); 345 | return '鍾'; 346 | case 3: 347 | defaultLogger.log('此位置屬於江韻'); 348 | return '江'; 349 | case 4: 350 | case 5: 351 | defaultLogger.log('此位置屬於支韻'); 352 | return '支'; 353 | case 6: 354 | case 7: 355 | defaultLogger.log('此位置屬於脂韻'); 356 | return '脂'; 357 | case 8: 358 | defaultLogger.log('此位置屬於之韻'); 359 | return '之'; 360 | case 9: 361 | case 10: 362 | if (raw聲 === '入') { 363 | defaultLogger.log('此位置屬於廢韻'); 364 | return '廢'; 365 | } 366 | defaultLogger.log('此位置屬於微韻'); 367 | return '微'; 368 | case 11: 369 | defaultLogger.log('此位置屬於魚韻'); 370 | return '魚'; 371 | case 12: 372 | if (韻鏡等 === 1) { 373 | defaultLogger.log('此位置屬於模韻'); 374 | return '模'; 375 | } 376 | defaultLogger.log('此位置屬於虞韻'); 377 | return '虞'; 378 | case 13: 379 | if (raw聲 === '入') { 380 | defaultLogger.log('此位置屬於夬韻'); 381 | return '夬'; 382 | } 383 | if (韻鏡等 === 1) { 384 | defaultLogger.log('此位置屬於咍韻'); 385 | return '咍'; 386 | } 387 | if (韻鏡等 === 2) { 388 | defaultLogger.log('此位置屬於皆韻'); 389 | return '皆'; 390 | } 391 | if (韻鏡等 === 4) { 392 | defaultLogger.log('此位置屬於齊韻'); 393 | return '齊'; 394 | } 395 | if (韻鏡等 === 3) { 396 | if (raw聲 === '去') { 397 | defaultLogger.log('此位置屬於祭韻'); 398 | return '祭'; 399 | } 400 | defaultLogger.log(`${轉名稱}、平上聲、韻鏡三等未標註對應韻,實際為咍韻`); 401 | return '咍'; // 咍韻三等平上聲均為特殊字,而咍韻三等去聲恰好無字,該處所排入字全為祭韻字。祭韻字佔用去聲位 402 | } 403 | throw new Error(`invalid 韻鏡等 ${韻鏡等}`); 404 | case 14: 405 | if (raw聲 === '入') { 406 | defaultLogger.log('此位置屬於夬韻'); 407 | return '夬'; 408 | } 409 | if (韻鏡等 === 1) { 410 | defaultLogger.log('此位置屬於灰韻'); 411 | return '灰'; 412 | } 413 | if (韻鏡等 === 2) { 414 | defaultLogger.log('此位置屬於皆韻'); 415 | return '皆'; 416 | } 417 | if (韻鏡等 === 4) { 418 | defaultLogger.log('此位置屬於齊韻'); 419 | return '齊'; 420 | } 421 | if (韻鏡等 === 3) { 422 | if (raw聲 === '去') { 423 | defaultLogger.log('此位置屬於祭韻'); 424 | return '祭'; 425 | } 426 | throw new Error(`invalid combination 轉 14 韻鏡三等${raw聲}聲`); // 祭韻字佔用去聲位 427 | } 428 | throw new Error('error'); 429 | case 15: 430 | case 16: 431 | if (韻鏡等 === 2) { 432 | defaultLogger.log('此位置屬於佳韻'); 433 | return '佳'; 434 | } 435 | if (raw聲 === '去') { 436 | if (韻鏡等 === 1) { 437 | defaultLogger.log('此位置屬於泰韻'); 438 | return '泰'; 439 | } 440 | if (韻鏡等 === 4) { 441 | defaultLogger.log('此位置屬於祭韻'); 442 | return '祭'; 443 | } 444 | } 445 | throw new Error('error'); 446 | case 17: 447 | if (韻鏡等 === 1) { 448 | defaultLogger.log('此位置屬於痕韻'); 449 | return '痕'; 450 | } 451 | if (韻鏡等 === 3 || 韻鏡等 === 4) { 452 | defaultLogger.log('此位置屬於真韻'); 453 | return '真'; 454 | } 455 | if (韻鏡等 === 2) { 456 | defaultLogger.log('此位置屬於臻韻'); 457 | return '臻'; 458 | } 459 | throw new Error('error'); 460 | case 18: 461 | if (韻鏡等 === 1) { 462 | defaultLogger.log('此位置屬於魂韻'); 463 | return '魂'; 464 | } 465 | defaultLogger.log('此位置屬於真韻'); 466 | return '真'; 467 | case 19: 468 | defaultLogger.log('此位置屬於殷韻'); 469 | return '殷'; 470 | case 20: 471 | defaultLogger.log('此位置屬於文韻'); 472 | return '文'; 473 | case 21: 474 | case 22: 475 | if (韻鏡等 === 3) { 476 | defaultLogger.log('此位置屬於元韻'); 477 | return '元'; 478 | } 479 | if (韻鏡等 === 4) { 480 | defaultLogger.log('此位置屬於仙韻'); 481 | return '仙'; 482 | } 483 | if (韻鏡等 === 2) { 484 | if (raw聲 === '入') { 485 | defaultLogger.log(`此位置標註為山韻,但${轉名稱}、入聲、韻鏡二等刪、山韻排反,實際為刪韻`); 486 | return '刪'; 487 | } 488 | defaultLogger.log('此位置屬於山韻'); 489 | return '山'; 490 | } 491 | throw new Error('error'); 492 | case 23: 493 | case 24: 494 | if (韻鏡等 === 1) { 495 | defaultLogger.log('此位置屬於寒韻'); 496 | return '寒'; 497 | } 498 | if (韻鏡等 === 3) { 499 | defaultLogger.log('此位置屬於仙韻'); 500 | return '仙'; 501 | } 502 | if (韻鏡等 === 4) { 503 | defaultLogger.log('此位置屬於先韻'); 504 | return '先'; 505 | } 506 | if (韻鏡等 === 2) { 507 | if (raw聲 === '入') { 508 | defaultLogger.log(`此位置標註為刪韻,但${轉名稱}、入聲、韻鏡二等刪、山韻排反,實際為山韻`); 509 | return '山'; 510 | } 511 | defaultLogger.log('此位置屬於刪韻'); 512 | return '刪'; // TODO: 處理仙韻 (see tests) 513 | } 514 | throw new Error('error'); 515 | case 25: 516 | if (韻鏡等 === 1) { 517 | defaultLogger.log('此位置屬於豪韻'); 518 | return '豪'; 519 | } 520 | if (韻鏡等 === 2) { 521 | defaultLogger.log('此位置屬於肴韻'); 522 | return '肴'; 523 | } 524 | if (韻鏡等 === 3) { 525 | defaultLogger.log('此位置屬於宵韻'); 526 | return '宵'; 527 | } 528 | if (韻鏡等 === 4) { 529 | defaultLogger.log('此位置屬於蕭韻'); 530 | return '蕭'; 531 | } 532 | throw new Error(`invalid 韻鏡等 ${韻鏡等}`); 533 | case 26: 534 | defaultLogger.log('此位置屬於宵韻'); 535 | return '宵'; 536 | case 27: 537 | case 28: 538 | defaultLogger.log('此位置屬於歌韻'); 539 | return '歌'; 540 | case 29: 541 | case 30: 542 | defaultLogger.log('此位置屬於麻韻'); 543 | return '麻'; 544 | case 31: 545 | case 32: 546 | if (韻鏡等 === 1) { 547 | defaultLogger.log('此位置屬於唐韻'); 548 | return '唐'; 549 | } 550 | defaultLogger.log('此位置屬於陽韻'); 551 | return '陽'; 552 | case 33: 553 | case 34: 554 | if (韻鏡等 === 2 || 韻鏡等 === 3) { 555 | defaultLogger.log('此位置屬於庚韻'); 556 | return '庚'; 557 | } 558 | if (韻鏡等 === 4) { 559 | defaultLogger.log('此位置屬於清韻'); 560 | return '清'; 561 | } 562 | throw new Error('error'); 563 | case 35: 564 | if (韻鏡等 === 2) { 565 | defaultLogger.log('此位置屬於耕韻'); 566 | return '耕'; 567 | } 568 | if (韻鏡等 === 3) { 569 | defaultLogger.log('此位置屬於清韻'); 570 | return '清'; 571 | } 572 | if (韻鏡等 === 4) { 573 | defaultLogger.log('此位置屬於青韻'); 574 | return '青'; 575 | } 576 | throw new Error('error'); 577 | case 36: 578 | if (韻鏡等 === 2) { 579 | defaultLogger.log('此位置屬於耕韻'); 580 | return '耕'; 581 | } 582 | if (韻鏡等 === 4) { 583 | defaultLogger.log('此位置屬於青韻'); 584 | return '青'; 585 | } 586 | throw new Error('error'); 587 | case 37: 588 | if (韻鏡等 === 1) { 589 | defaultLogger.log('此位置屬於侯韻'); 590 | return '侯'; 591 | } 592 | if (韻鏡等 === 2 || 韻鏡等 === 3) { 593 | defaultLogger.log('此位置屬於尤韻'); 594 | return '尤'; 595 | } 596 | if (韻鏡等 === 4) { 597 | if (is齒音 || 右位 === 21) { 598 | defaultLogger.log(`此位置標註為幽韻,但${轉名稱}、韻鏡四等位有尤、幽二韻混排,其中齒音與以母為尤韻`); 599 | return '尤'; // 尤、幽韻在韻鏡四等混排,齒音與以母為尤韻 600 | } 601 | defaultLogger.log(`此位置標註為幽韻,但${轉名稱}、韻鏡四等位有尤、幽二韻混排,其中非齒音且非以母為幽韻`); 602 | return '幽'; 603 | } 604 | throw new Error(`invalid 韻鏡等 ${韻鏡等}`); 605 | case 38: 606 | defaultLogger.log('此位置屬於侵韻'); 607 | return '侵'; 608 | case 39: 609 | if (韻鏡等 === 1) { 610 | defaultLogger.log('此位置屬於覃韻'); 611 | return '覃'; 612 | } 613 | if (韻鏡等 === 2) { 614 | defaultLogger.log('此位置屬於咸韻'); 615 | return '咸'; 616 | } 617 | if (韻鏡等 === 3) { 618 | defaultLogger.log('此位置屬於鹽韻'); 619 | return '鹽'; 620 | } 621 | if (韻鏡等 === 4) { 622 | defaultLogger.log('此位置屬於添韻'); 623 | return '添'; 624 | } 625 | throw new Error(`invalid 韻鏡等 ${韻鏡等}`); 626 | case 40: 627 | if (韻鏡等 === 1) { 628 | defaultLogger.log('此位置屬於談韻'); 629 | return '談'; 630 | } 631 | if (韻鏡等 === 2) { 632 | defaultLogger.log('此位置屬於銜韻'); 633 | return '銜'; 634 | } 635 | if (韻鏡等 === 3) { 636 | defaultLogger.log('此位置屬於嚴韻'); 637 | return '嚴'; 638 | } 639 | if (韻鏡等 === 4) { 640 | defaultLogger.log('此位置屬於鹽韻'); 641 | return '鹽'; 642 | } 643 | throw new Error(`invalid 韻鏡等 ${韻鏡等}`); 644 | case 41: 645 | defaultLogger.log('此位置屬於凡韻'); 646 | return '凡'; 647 | case 42: 648 | case 43: 649 | if (韻鏡等 === 1) { 650 | defaultLogger.log('此位置屬於登韻'); 651 | return '登'; 652 | } 653 | defaultLogger.log('此位置屬於蒸韻'); 654 | return '蒸'; 655 | default: 656 | throw new Error('error'); 657 | } 658 | }; 659 | 660 | export const 音韻地位2韻鏡位置 = (當前音韻地位: 音韻地位) => { 661 | const { 母, 呼, 等, 類, 韻, 聲 } = 當前音韻地位; 662 | 663 | // calculate 韻鏡等 664 | const needAddOne = 類 === 'A' || ([...'精清從心邪以'].includes(母) && 等 === '三') || 韻 === '幽'; // 重紐四等、假四等真三等、幽韻為四等位 665 | const needMinusOne = [...'莊初崇生俟'].includes(母) && 等 === '三'; // 假二等真三等 666 | const 韻鏡等 = [...'一二三四'].indexOf(等) + 1 + (needAddOne ? 1 : needMinusOne ? -1 : 0); 667 | 668 | // calculate 轉號 669 | const 轉號 = _母類呼韻聲2轉號(母, 呼, 類, 韻, 聲, 韻鏡等); 670 | 671 | // calculate 上位 672 | const need寄入 = [...'廢夬'].includes(韻); // 標註「去聲寄此」 673 | const 上位 = (need寄入 ? 3 : [...'平上去入'].indexOf(聲)) * 4 + 韻鏡等; 674 | 675 | // calculate 右位 676 | const 右位 = 母idx2右位[母2idx.indexOf(母)]; // TODO: 常船位置 677 | 678 | return new 韻鏡位置(轉號, 上位, 右位); 679 | }; 680 | 681 | const _母類呼韻聲2轉號 = (母: string, 呼: string | null, 類: string | null, 韻: string, 聲: string, 韻鏡等: number) => { 682 | if (呼 === null) { 683 | // TODO: cannot use 開口? 684 | const shouldUse合口 = 685 | [...'微廢夬元寒歌'].includes(韻) || 686 | ([...'佳皆'].includes(韻) && 聲 === '去') || 687 | (韻 === '刪' && 聲 !== '入') || 688 | (韻 === '山' && 聲 === '入') || 689 | (韻 === '仙' && 聲 === '去' && 類 === 'B'); 690 | 呼 = shouldUse合口 ? '合' : '開'; 691 | } 692 | 693 | switch (韻) { 694 | case '東': 695 | return 1; 696 | case '冬': 697 | case '鍾': 698 | return 2; 699 | case '江': 700 | return 3; 701 | case '支': 702 | if (呼 === '開') { 703 | return 4; 704 | } 705 | return 5; 706 | case '脂': 707 | if (呼 === '開') { 708 | return 6; 709 | } 710 | return 7; 711 | case '之': 712 | return 8; 713 | case '微': 714 | if (呼 === '開') { 715 | return 9; 716 | } 717 | return 10; 718 | case '廢': 719 | if (呼 === '開') { 720 | return 9; 721 | } 722 | return 10; 723 | case '魚': 724 | return 11; 725 | case '虞': 726 | case '模': 727 | return 12; 728 | case '齊': 729 | case '夬': 730 | case '皆': 731 | if (呼 === '開') { 732 | return 13; 733 | } 734 | return 14; 735 | case '咍': 736 | return 13; 737 | case '灰': 738 | return 14; 739 | case '祭': 740 | if (韻鏡等 === 3) { 741 | if (呼 === '開') { 742 | return 13; 743 | } 744 | return 14; 745 | } else if (韻鏡等 === 4) { 746 | if (呼 === '開') { 747 | return 15; 748 | } 749 | return 16; 750 | } 751 | throw new Error(`韻鏡等 ${韻鏡等} invalid for 祭韻`); 752 | case '佳': 753 | case '泰': 754 | if (呼 === '開') { 755 | return 15; 756 | } 757 | return 16; 758 | case '痕': 759 | case '臻': 760 | return 17; 761 | case '魂': 762 | return 18; 763 | case '真': 764 | if (呼 === '開') { 765 | return 17; 766 | } 767 | return 18; 768 | case '殷': 769 | return 19; 770 | case '文': 771 | return 20; 772 | case '元': 773 | if (呼 === '開') { 774 | return 21; 775 | } 776 | return 22; 777 | case '山': 778 | if (聲 === '入') { 779 | if (呼 === '開') { 780 | return 23; 781 | } 782 | return 24; 783 | } 784 | if (呼 === '開') { 785 | return 21; 786 | } 787 | return 22; 788 | case '刪': 789 | if (聲 === '入') { 790 | if (呼 === '開') { 791 | return 21; 792 | } 793 | return 22; 794 | } 795 | if (呼 === '開') { 796 | return 23; 797 | } 798 | return 24; 799 | case '仙': 800 | if (韻鏡等 === 3) { 801 | if (呼 === '開') { 802 | return 23; 803 | } 804 | return 24; 805 | } else if (韻鏡等 === 2 || 韻鏡等 === 4) { 806 | if (呼 === '開') { 807 | return 21; 808 | } 809 | return 22; 810 | } 811 | throw new Error('error'); 812 | case '寒': 813 | case '先': 814 | if (呼 === '開') { 815 | return 23; 816 | } 817 | return 24; 818 | case '蕭': 819 | case '肴': 820 | case '豪': 821 | return 25; 822 | case '宵': 823 | if (類 === 'A' || [...'精清從心邪以'].includes(母)) { 824 | return 26; 825 | } 826 | return 25; 827 | case '歌': 828 | if (呼 === '開') { 829 | return 27; 830 | } 831 | return 28; 832 | case '麻': 833 | if (呼 === '開') { 834 | return 29; 835 | } 836 | return 30; 837 | case '陽': 838 | case '唐': 839 | if (呼 === '開') { 840 | return 31; 841 | } 842 | return 32; 843 | case '庚': 844 | if (呼 === '開') { 845 | return 33; 846 | } 847 | return 34; 848 | case '清': 849 | if (韻鏡等 === 3) { 850 | if (呼 === '開') { 851 | return 35; 852 | } 853 | throw new Error('error: no 合口'); 854 | } else if (韻鏡等 === 4) { 855 | if (呼 === '開') { 856 | return 33; 857 | } 858 | return 34; 859 | } 860 | throw new Error('error'); 861 | case '耕': 862 | case '青': 863 | if (呼 === '開') { 864 | return 35; 865 | } 866 | return 36; 867 | case '尤': 868 | case '侯': 869 | case '幽': 870 | return 37; 871 | case '侵': 872 | return 38; 873 | case '鹽': 874 | if (韻鏡等 === 3) { 875 | return 39; 876 | } else if (韻鏡等 === 4) { 877 | return 40; 878 | } 879 | throw new Error(`韻鏡等 ${韻鏡等} invalid for 鹽韻`); 880 | case '覃': 881 | case '咸': 882 | case '添': 883 | return 39; 884 | case '談': 885 | case '銜': 886 | return 40; 887 | case '嚴': 888 | if (韻鏡等 === 3) { 889 | return 40; 890 | } 891 | throw new Error(`韻鏡等 ${韻鏡等} invalid for 嚴韻`); 892 | case '凡': 893 | return 41; 894 | case '登': 895 | case '蒸': 896 | if (呼 === '開') { 897 | return 42; 898 | } 899 | return 43; 900 | default: 901 | throw new Error('error'); 902 | } 903 | }; 904 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------