From f0cfde61de15c384f1d4f11bc85bd8e0fc650e13 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 22 Aug 2026 10:34:22 +0800 Subject: [PATCH 1/4] feat(pinyin): convert first of two third tones to second tone --- packages/pinyin-pro/lib/core/pinyin/index.ts | 2 +- .../pinyin-pro/lib/core/pinyin/middlewares.ts | 47 +++++++++++++++ packages/pinyin-pro/lib/core/segment/index.ts | 2 +- packages/pinyin-pro/test/basic.test.js | 2 +- packages/pinyin-pro/test/segment.test.js | 4 +- packages/pinyin-pro/test/special.test.js | 4 +- packages/pinyin-pro/test/toneSandhi.test.js | 59 ++++++++++++++++++- 7 files changed, 111 insertions(+), 9 deletions(-) diff --git a/packages/pinyin-pro/lib/core/pinyin/index.ts b/packages/pinyin-pro/lib/core/pinyin/index.ts index 64dc5c95..ebcc0ecf 100644 --- a/packages/pinyin-pro/lib/core/pinyin/index.ts +++ b/packages/pinyin-pro/lib/core/pinyin/index.ts @@ -204,7 +204,7 @@ function pinyin( zhChars, ); - // 一和不变调处理 + // 变调处理 list = middlewareToneSandhi(list, options.toneSandhi as boolean); // nonZh 参数及 removeNonZh 参数 diff --git a/packages/pinyin-pro/lib/core/pinyin/middlewares.ts b/packages/pinyin-pro/lib/core/pinyin/middlewares.ts index d74d6761..300879b8 100644 --- a/packages/pinyin-pro/lib/core/pinyin/middlewares.ts +++ b/packages/pinyin-pro/lib/core/pinyin/middlewares.ts @@ -235,6 +235,28 @@ export const middlewareType = ( return list.map((item) => item.result).join(options.separator); }; +const thirdToneToSecondToneMap = { + ǎ: "á", + ǒ: "ó", + ě: "é", + ǐ: "í", + ǔ: "ú", + ǚ: "ǘ", + ň: "ń", + "m̌": "ḿ", + "ê̌": "ế", +}; + +const convertThirdToneToSecondTone = (pinyin: string) => { + return pinyin.replace( + /ǎ|ǒ|ě|ǐ|ǔ|ǚ|ň|m̌|ê̌/, + (thirdTone) => + thirdToneToSecondToneMap[ + thirdTone as keyof typeof thirdToneToSecondToneMap + ], + ); +}; + // 是否开启变调 export const middlewareToneSandhi = ( list: SingleWordResult[], @@ -248,6 +270,31 @@ export const middlewareToneSandhi = ( item.result = item.originPinyin = "bù"; } }); + return list; + } + + for (let start = 0; start < list.length; ) { + if (!list[start].isZh || getNumOfTone(list[start].result) !== "3") { + start += 1; + continue; + } + + let end = start + 1; + while ( + end < list.length && + list[end].isZh && + getNumOfTone(list[end].result) === "3" + ) { + end += 1; + } + + if (end - start === 2) { + const pinyin = convertThirdToneToSecondTone(list[start].result); + list[start].result = pinyin; + list[start].originPinyin = pinyin; + } + + start = end; } return list; }; diff --git a/packages/pinyin-pro/lib/core/segment/index.ts b/packages/pinyin-pro/lib/core/segment/index.ts index 8f550765..0ed569bb 100644 --- a/packages/pinyin-pro/lib/core/segment/index.ts +++ b/packages/pinyin-pro/lib/core/segment/index.ts @@ -145,7 +145,7 @@ export function segment(word: string, options?: SegmentCompleteOptions) { zhChars, ); - // 一和不变调处理 + // 变调处理 list = middlewareToneSandhi(list, options.toneSandhi as boolean); // nonZh diff --git a/packages/pinyin-pro/test/basic.test.js b/packages/pinyin-pro/test/basic.test.js index 290e511b..1fdc694d 100644 --- a/packages/pinyin-pro/test/basic.test.js +++ b/packages/pinyin-pro/test/basic.test.js @@ -23,7 +23,7 @@ describe('basic', () => { it('[basic]好好', () => { const result = pinyin('好好学习'); - expect(result).to.be.equal('hǎo hǎo xué xí'); + expect(result).to.be.equal('háo hǎo xué xí'); }); it('[basic]拼音+非汉字数组', () => { diff --git a/packages/pinyin-pro/test/segment.test.js b/packages/pinyin-pro/test/segment.test.js index cd4e1beb..1c125dab 100644 --- a/packages/pinyin-pro/test/segment.test.js +++ b/packages/pinyin-pro/test/segment.test.js @@ -346,7 +346,7 @@ describe("segment surname mode", () => { format: OutputFormat.PinyinString } ); - expect(result).to.be.equal("zēng xiǎo xián nǐhǎo") + expect(result).to.be.equal("zēng xiǎo xián níhǎo") }); }); @@ -379,4 +379,4 @@ describe("segment final nonZh", () => { "xiǎo míng shuòshì bìyè yú zhōngguókēxuéyuàn jìsuànsuǒ , hòu zài rìběnjīngdūdàxué shēnzào 。" ); }); -}); \ No newline at end of file +}); diff --git a/packages/pinyin-pro/test/special.test.js b/packages/pinyin-pro/test/special.test.js index 351ef5e3..b547d7b0 100644 --- a/packages/pinyin-pro/test/special.test.js +++ b/packages/pinyin-pro/test/special.test.js @@ -232,7 +232,7 @@ describe("tone sandhi for “不”", () => { it("[special tone sandhi]要不你走", () => { const result = pinyin("要不你走"); - expect(result).to.be.equal("yào bù nǐ zǒu"); + expect(result).to.be.equal("yào bù ní zǒu"); }); // 变调为二声 @@ -294,7 +294,7 @@ describe('[special tone sandhi]绕口令', () => { it('[special 々]々', () => { const result = pinyin('天々向上,好々学习'); - expect(result).to.be.equal('tiān tiān xiàng shàng , hǎo hǎo xué xí'); + expect(result).to.be.equal('tiān tiān xiàng shàng , háo hǎo xué xí'); const result1 = pinyin('々々'); expect(result1).to.be.equal('tóng tóng'); diff --git a/packages/pinyin-pro/test/toneSandhi.test.js b/packages/pinyin-pro/test/toneSandhi.test.js index 196b54ae..4acc5e48 100644 --- a/packages/pinyin-pro/test/toneSandhi.test.js +++ b/packages/pinyin-pro/test/toneSandhi.test.js @@ -1,10 +1,65 @@ -import { pinyin } from '../lib/index'; -import { expect, describe, it } from 'vitest'; +import { clearCustomDict, customPinyin, pinyin } from '../lib/index'; +import { afterEach, expect, describe, it } from 'vitest'; describe("toneSandhi", () => { + afterEach(() => { + clearCustomDict('pinyin'); + }); + it("[toneSandhi]不", () => { const result = pinyin("不是", { toneSandhi: false }); expect(result).to.be.equal("bù shì"); }); + it("changes the first of two consecutive third tones", () => { + expect(pinyin("你好", { toneSandhi: true })).to.be.equal("ní hǎo"); + expect(pinyin("你高", { toneSandhi: true })).to.be.equal("nǐ gāo"); + }); + + it("supports each tone output format", () => { + expect( + pinyin("你好", { toneSandhi: true, toneType: "symbol" }), + ).to.be.equal("ní hǎo"); + expect( + pinyin("你好", { toneSandhi: true, toneType: "num" }), + ).to.be.equal("ni2 hao3"); + expect( + pinyin("你好", { toneSandhi: true, toneType: "none" }), + ).to.be.equal("ni hao"); + }); + + it("keeps both third tones when tone sandhi is disabled", () => { + expect(pinyin("你好", { toneSandhi: false })).to.be.equal("nǐ hǎo"); + }); + + it("does not apply across non-Chinese boundaries", () => { + expect(pinyin("你,好", { toneSandhi: true })).to.be.equal("nǐ , hǎo"); + expect(pinyin("你a好", { toneSandhi: true })).to.be.equal("nǐ a hǎo"); + }); + + it("handles separate pairs between punctuation boundaries", () => { + expect(pinyin("你好,很好", { toneSandhi: true })).to.be.equal( + "ní hǎo , hén hǎo", + ); + }); + + it("keeps runs longer than two third tones unchanged", () => { + expect(pinyin("我很好", { toneSandhi: true })).to.be.equal("wǒ hěn hǎo"); + expect(pinyin("我想很好", { toneSandhi: true })).to.be.equal( + "wǒ xiǎng hěn hǎo", + ); + }); + + it("uses custom pinyin results for third-tone sandhi", () => { + customPinyin({ + 你好: "wǒ hǎo", + }); + + expect(pinyin("你好", { toneSandhi: true })).to.be.equal("wó hǎo"); + }); + + it("preserves existing tone sandhi for 一 and 不", () => { + expect(pinyin("一把", { toneSandhi: true })).to.be.equal("yì bǎ"); + expect(pinyin("不想", { toneSandhi: true })).to.be.equal("bù xiǎng"); + }); }); From bdf8bfb774ab4c8eea3d90bef2e8de18e2525a80 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 22 Aug 2026 10:51:06 +0800 Subject: [PATCH 2/4] perf(pinyin): detect third tones with light regex instead of tone classifier --- .../pinyin-pro/lib/core/pinyin/middlewares.ts | 16 +++++++++------- packages/pinyin-pro/test/toneSandhi.test.js | 10 ++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/pinyin-pro/lib/core/pinyin/middlewares.ts b/packages/pinyin-pro/lib/core/pinyin/middlewares.ts index 300879b8..fc2e1f82 100644 --- a/packages/pinyin-pro/lib/core/pinyin/middlewares.ts +++ b/packages/pinyin-pro/lib/core/pinyin/middlewares.ts @@ -247,9 +247,15 @@ const thirdToneToSecondToneMap = { "ê̌": "ế", }; +// 轻量三声判断:避免在默认热路径上调用完整的 getNumOfTone +const thirdTonePattern = /ǎ|ǒ|ě|ǐ|ǔ|ǚ|ň|m̌|ê̌/; + +const isThirdTone = (item: SingleWordResult) => + item.isZh && thirdTonePattern.test(item.result); + const convertThirdToneToSecondTone = (pinyin: string) => { return pinyin.replace( - /ǎ|ǒ|ě|ǐ|ǔ|ǚ|ň|m̌|ê̌/, + thirdTonePattern, (thirdTone) => thirdToneToSecondToneMap[ thirdTone as keyof typeof thirdToneToSecondToneMap @@ -274,17 +280,13 @@ export const middlewareToneSandhi = ( } for (let start = 0; start < list.length; ) { - if (!list[start].isZh || getNumOfTone(list[start].result) !== "3") { + if (!isThirdTone(list[start])) { start += 1; continue; } let end = start + 1; - while ( - end < list.length && - list[end].isZh && - getNumOfTone(list[end].result) === "3" - ) { + while (end < list.length && isThirdTone(list[end])) { end += 1; } diff --git a/packages/pinyin-pro/test/toneSandhi.test.js b/packages/pinyin-pro/test/toneSandhi.test.js index 4acc5e48..b843993f 100644 --- a/packages/pinyin-pro/test/toneSandhi.test.js +++ b/packages/pinyin-pro/test/toneSandhi.test.js @@ -28,6 +28,16 @@ describe("toneSandhi", () => { ).to.be.equal("ni hao"); }); + it("reproduces the issue 286 example", () => { + expect( + pinyin("你好", { + toneSandhi: true, + type: "array", + toneType: "num", + }), + ).toEqual(["ni2", "hao3"]); + }); + it("keeps both third tones when tone sandhi is disabled", () => { expect(pinyin("你好", { toneSandhi: false })).to.be.equal("nǐ hǎo"); }); From 2608409678b8258d6e764b4456369de29d388740 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 22 Aug 2026 10:51:06 +0800 Subject: [PATCH 3/4] docs(pinyin): describe third-tone sandhi in option comments and param tables --- .../en/.vitepress/theme/components/basic-params-table.vue | 8 +++++--- .../zh/.vitepress/theme/components/basic-params-table.vue | 8 +++++--- packages/pinyin-pro/lib/core/pinyin/index.ts | 2 +- packages/pinyin-pro/types/core/pinyin/index.d.ts | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/docs/en/.vitepress/theme/components/basic-params-table.vue b/packages/docs/en/.vitepress/theme/components/basic-params-table.vue index 3b4fecd9..b9d60faf 100644 --- a/packages/docs/en/.vitepress/theme/components/basic-params-table.vue +++ b/packages/docs/en/.vitepress/theme/components/basic-params-table.vue @@ -348,18 +348,20 @@ pinyin('赵钱孙李额', { pattern: 'first', toneType: 'none', type: 'array' }) option: 'toneSandhi', type: 'boolean', description: - 'whether to apply smart tone change to and , reference 维基百科', + 'whether to apply smart tone change to , and two consecutive third-tone syllables (e.g. 你好 → ní hǎo), reference 维基百科', default: 'true', children: [ { value: 'true', desc: 'apply', - example: `pinyin('一旦被发现', { toneSandhi: true }); // 'yí dàn bèi fā xiàn'`, + example: `pinyin('一旦被发现', { toneSandhi: true }); // 'yí dàn bèi fā xiàn' +pinyin('你好', { toneSandhi: true }); // 'ní hǎo'`, }, { value: 'false', desc: 'not apply', - example: `pinyin('一旦被发现', { toneSandhi: false }); // 'yī dàn bèi fā xiàn'`, + example: `pinyin('一旦被发现', { toneSandhi: false }); // 'yī dàn bèi fā xiàn' +pinyin('你好', { toneSandhi: false }); // 'nǐ hǎo'`, }, ], }, diff --git a/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue b/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue index 4151acaf..19121325 100644 --- a/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue +++ b/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue @@ -346,18 +346,20 @@ pinyin('赵钱孙李额', { pattern: 'first', toneType: 'none', type: 'array' }) option: 'toneSandhi', type: 'boolean', description: - '是否对应用智能变调,参考维基百科', + '是否应用智能变调,包括以及两个连续三声的变调(如 你好 → ní hǎo),参考维基百科', default: 'true', children: [ { value: 'true', desc: '应用', - example: `pinyin('一旦被发现', { toneSandhi: true }); // 'yí dàn bèi fā xiàn'`, + example: `pinyin('一旦被发现', { toneSandhi: true }); // 'yí dàn bèi fā xiàn' +pinyin('你好', { toneSandhi: true }); // 'ní hǎo'`, }, { value: 'false', desc: '不应用', - example: `pinyin('一旦被发现', { toneSandhi: false }); // 'yī dàn bèi fā xiàn'`, + example: `pinyin('一旦被发现', { toneSandhi: false }); // 'yī dàn bèi fā xiàn' +pinyin('你好', { toneSandhi: false }); // 'nǐ hǎo'`, }, ], }, diff --git a/packages/pinyin-pro/lib/core/pinyin/index.ts b/packages/pinyin-pro/lib/core/pinyin/index.ts index ebcc0ecf..6742e2ec 100644 --- a/packages/pinyin-pro/lib/core/pinyin/index.ts +++ b/packages/pinyin-pro/lib/core/pinyin/index.ts @@ -34,7 +34,7 @@ export interface BasicOptions extends CommonOptions { */ surname?: SurnameMode; /** - * @description 是否开启「一」和 「不」字的变调。默认开启。参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83 + * @description 是否开启变调。默认开启。包含「一」和「不」的变调,以及两个连续三声的变调(如 你好 → ní hǎo)。参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83 * @value true:开启 * @value false:不开启 */ diff --git a/packages/pinyin-pro/types/core/pinyin/index.d.ts b/packages/pinyin-pro/types/core/pinyin/index.d.ts index e36aa0c1..467cfa26 100644 --- a/packages/pinyin-pro/types/core/pinyin/index.d.ts +++ b/packages/pinyin-pro/types/core/pinyin/index.d.ts @@ -21,7 +21,7 @@ export interface BasicOptions extends CommonOptions { */ surname?: SurnameMode; /** - * @description 是否开启「一」和 「不」字的变调。默认开启。参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83 + * @description 是否开启变调。默认开启。包含「一」和「不」的变调,以及两个连续三声的变调(如 你好 → ní hǎo)。参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83 * @value true:开启 * @value false:不开启 */ From 9417b7cc752f3f71a2ae8cd03d32db04077aa022 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 22 Aug 2026 11:00:10 +0800 Subject: [PATCH 4/4] docs(pinyin): add third-tone sandhi reference link --- .../docs/en/.vitepress/theme/components/basic-params-table.vue | 2 +- .../docs/zh/.vitepress/theme/components/basic-params-table.vue | 2 +- packages/pinyin-pro/lib/core/pinyin/index.ts | 2 +- packages/pinyin-pro/types/core/pinyin/index.d.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/docs/en/.vitepress/theme/components/basic-params-table.vue b/packages/docs/en/.vitepress/theme/components/basic-params-table.vue index b9d60faf..402044e1 100644 --- a/packages/docs/en/.vitepress/theme/components/basic-params-table.vue +++ b/packages/docs/en/.vitepress/theme/components/basic-params-table.vue @@ -348,7 +348,7 @@ pinyin('赵钱孙李额', { pattern: 'first', toneType: 'none', type: 'array' }) option: 'toneSandhi', type: 'boolean', description: - 'whether to apply smart tone change to , and two consecutive third-tone syllables (e.g. 你好 → ní hǎo), reference 维基百科', + 'whether to apply smart tone change to and , reference 维基百科, and to two consecutive third-tone syllables (e.g. 你好 → ní hǎo), reference Wikipedia', default: 'true', children: [ { diff --git a/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue b/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue index 19121325..b4439ad3 100644 --- a/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue +++ b/packages/docs/zh/.vitepress/theme/components/basic-params-table.vue @@ -346,7 +346,7 @@ pinyin('赵钱孙李额', { pattern: 'first', toneType: 'none', type: 'array' }) option: 'toneSandhi', type: 'boolean', description: - '是否应用智能变调,包括以及两个连续三声的变调(如 你好 → ní hǎo),参考维基百科', + '是否应用智能变调,包括(参考维基百科),以及两个连续三声的变调(如 你好 → ní hǎo,参考Wikipedia)', default: 'true', children: [ { diff --git a/packages/pinyin-pro/lib/core/pinyin/index.ts b/packages/pinyin-pro/lib/core/pinyin/index.ts index 6742e2ec..9624564e 100644 --- a/packages/pinyin-pro/lib/core/pinyin/index.ts +++ b/packages/pinyin-pro/lib/core/pinyin/index.ts @@ -34,7 +34,7 @@ export interface BasicOptions extends CommonOptions { */ surname?: SurnameMode; /** - * @description 是否开启变调。默认开启。包含「一」和「不」的变调,以及两个连续三声的变调(如 你好 → ní hǎo)。参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83 + * @description 是否开启变调。默认开启。包含「一」和「不」的变调(参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83),以及两个连续三声的变调(如 你好 → ní hǎo,参考:https://en.wikipedia.org/wiki/Tone_sandhi#Mandarin_Chinese) * @value true:开启 * @value false:不开启 */ diff --git a/packages/pinyin-pro/types/core/pinyin/index.d.ts b/packages/pinyin-pro/types/core/pinyin/index.d.ts index 467cfa26..0316550d 100644 --- a/packages/pinyin-pro/types/core/pinyin/index.d.ts +++ b/packages/pinyin-pro/types/core/pinyin/index.d.ts @@ -21,7 +21,7 @@ export interface BasicOptions extends CommonOptions { */ surname?: SurnameMode; /** - * @description 是否开启变调。默认开启。包含「一」和「不」的变调,以及两个连续三声的变调(如 你好 → ní hǎo)。参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83 + * @description 是否开启变调。默认开启。包含「一」和「不」的变调(参考:https://zh.wiktionary.org/wiki/Appendix:%E2%80%9C%E4%B8%80%E2%80%9D%E5%8F%8A%E2%80%9C%E4%B8%8D%E2%80%9D%E7%9A%84%E5%8F%98%E8%B0%83),以及两个连续三声的变调(如 你好 → ní hǎo,参考:https://en.wikipedia.org/wiki/Tone_sandhi#Mandarin_Chinese) * @value true:开启 * @value false:不开启 */