注意:项目真实存在,本文完全 AI。上集:强制使用德语键盘输入中文(更新)

项目地址:https://github.com/fluviusmagnus/de-tastatur

在 macOS 上使用德语键盘,常见的路无非两条:用 Karabiner-Elements 之类的工具改键,或者将就系统自带的德语布局。

前者能解决问题,但代价不小:后台服务、驱动、辅助功能权限,以及一套需要持续维护的规则。后者看起来“系统自带”,实际常常缺东西——E1/E2 的 AltGr 层、完整的死键组合、扩展选择,以及德语布局应有的细节,很难同时齐全。

de-tastatur 选择了第三条路:直接生成 macOS 原生 .bundle 输入源。它不依赖 Karabiner,不需要后台服务,不申请辅助功能权限,也没有第三方运行时。系统把它当成一个普通的德语键盘布局,和其他输入源一样出现在菜单栏里。

项目当前版本是 1.2.0,已在 macOS 27.2、Apple Silicon、ANSI 实体键盘上验证。本文记录它做了什么、怎么做的,以及过程中那些只有真正交给 macOS 加载后才会暴露的问题。

目标:不是“能打出 ä”就够了

这个项目的目标不是做一个“德语字母补丁”,而是尽量完整地复刻 DIN 2137 的 E1/E2 行为。具体包括:

  • QWERTZ:美式键帽的 Y 位置输入 z,Z 位置输入 y。
  • 左右 Option 都是 AltGr:不需要 Control+Option 模拟。
  • 完整死键状态机:重音、多重重音、音标、希腊字母、数学符号、分数、上下标等。
  • Option+F 扩展选择:按下 Option+F,松开,再按目标键;Shift 选择对应大写或第二字符。
  • E2 ANSI 为主布局,E1 ISO 作为备用:两者使用不同的参考表和输入源 ID。

项目交付两个原生布局包:

布局名称适用硬件
German E2German E2 (ANSI)常见 ANSI 键盘
German E1German E1 (ISO)带左 Shift 额外键的 ISO 键盘

两个 bundle 都在 Info.plist 中通过 KLInfo_* 声明 TISIntendedLanguage=de。这一步很关键:只有声明了主语言,系统才会把布局归入“德语”分类,而不是丢进“其他”。

为什么选择原生输入源

原生 .keylayout 的问题很明显:格式老、文档少、调试工具不友好,出错时系统往往只给你一个“布局不生效”。

但它的优点也同样明显:

  • 没有常驻进程,没有事件拦截,不依赖辅助功能权限。
  • 系统级生效,应用看到的是正常的键盘布局,而不是被改写的按键事件。
  • 可以和输入法、Caps Lock、Command 快捷键等系统行为正确协作。
  • 安装方式就是一个 bundle 复制到 ~/Library/Keyboard Layouts/,卸载就是删掉它。

对于“我只是想要一个德语键盘布局”这种需求,原生方案更干净。代价是,你得自己把布局文件生成对,并且用系统真正加载后的结果来验证。

技术路线:从 Windows 表到 Apple keylayout

项目的生成器是纯 Python,不依赖第三方库。输入是固定版本的参考快照:

  • Linux xkeyboard-config 2.48 的 de(e1) / de(e2),用于物理键位参考。
  • Windows KBDGRE1.DLL / KBDGRE2.DLL 的公开布局表,用于字符、死键和扩展选择状态机。

参考文件全部带有 SHA-256 校验和。生成前先核对,避免“参考表悄悄变了,输出也跟着变”。

生成流程大致是:

  1. 解析 Windows 表:读取 VK_TO_WCHAR_TABLE 和 PhysicalKeys,把 PC 扫描码映射到 macOS 虚拟键码。
  2. 建立 10 套 keymap:普通、Shift、Caps、Shift+Caps、Option、Shift+Option,以及 Command/Control 的快捷键变体。
  3. 移植死键状态机:Windows 的 DEADKEY 表记录的是“当前重音状态 + 输入字符 → 结果字符/下一状态”。生成器把它翻译成 keylayout 的 action、state 和 when。
  4. 处理终止符:未完成的死键在遇到普通字符、Escape 或退格时如何收尾,需要和参考实现一致。
  5. 渲染 XML:输出 .keylayout,再打包成 .bundle,写入 Info.plist 和本地化字符串。

这里最容易出错的地方,是把死键当成“一个特殊字符”。它其实是一个状态:按下重音键后,键盘进入某个待定状态;下一个按键可能完成组合、进入另一个死键状态,或者因为不匹配而输出“可见前缀 + 当前字符”。

生成器还刻意避免把 Windows 的内部中间状态码直接输出成可见字符。不匹配的组合应该让用户看到重音前缀,而不是一个莫名其妙的符号。

扩展选择也是同一套机制:Option+F 本身是一个死键状态,后续按键从参考表里查结果。Shift 在这一步选择大写或第二字符,而不是要求用户一直按住 Option。

bundle 里的关键声明

生成出来的 Info.plist 大致是这样:

<key>KLInfo_GermanE2ANSI</key>
<dict>
    <key>TISInputSourceID</key>
    <string>local.keyboardlayout.GermanE2.keylayout.GermanE2ANSI</string>
    <key>TISIntendedLanguage</key>
    <string>de</string>
</dict>

TISInputSourceID 必须稳定,系统靠它识别和持久化输入源。TISIntendedLanguage 决定分类。显示名称则通过 Resources/de.lproj/InfoPlist.strings 和 en.lproj 本地化。

安装和注册是两件事。生成器本身不安装、不注册、不切换输入源;verify_macos.swift --register 也只注册,不启用或选择。这个边界很重要——自动化工具不应该在用户不知情时改变当前输入法。

那些只有原生加载才会暴露的坑

1. Apple 的编译器会“吃掉”XML 命名实体

离线测试全部通过后,第一次跑 macOS 原生验证,出现了 12 个失败——两个键盘类型各 6 个。

原因不是布局逻辑,而是 Apple 的 keylayout 编译器在处理 action 输出时,丢弃了 &amp;、&lt;、&gt; 这类命名实体。普通 XML 解析器完全接受这些写法,所以离线测试发现不了。

修复方式是改用数值引用:&#x0026;、&#x003C;、&#x003E;。同时增加离线回归测试,防止以后又改回命名实体。

这件事的教训很直接:能通过 XML 解析,不等于能通过 macOS 加载。 原生验证不是锦上添花,而是这个项目最核心的测试手段之一。

2. 输入源持久化有两个域

排查旧德语布局为什么停不掉时,发现了一个容易误判的事实:

  • 第三方输入源保存在 com.apple.inputsources/AppleEnabledThirdPartyInputSources。
  • 系统内置输入源保存在 com.apple.HIToolbox/AppleEnabledInputSources。

只看 HIToolbox,会误以为 E2 没有被保存;实际上它在第三方域里待得好好的。系统还会保留最后一个内置 ASCII 布局的外部行为,导致直接调用 TISDisableInputSource 返回成功,但重新枚举后它仍然启用。

项目为此写了计划、检查、应用、恢复四个步骤的工具:先备份,再生成计划,--check 只读检查,--apply 只写 AppleEnabledInputSources 这一个键,--restore 则要求当前状态仍与计划应用后一致才允许恢复,避免覆盖用户后来的修改。

即便如此,文档也没有把“写入成功”说成“问题已解决”。注销重登录后的持久效果仍需要用户验收;如果系统又恢复了旧项,那属于平台限制或缺陷,不能靠反复覆盖偏好来假装修复。

3. E1 迁移期间的一次输出缓冲区错误

迁移过程中,E1 的 UCKeyTranslate 曾出现一次输出缓冲区错误。重新注册后复测通过。验证工具后来增加了独立的键盘数据快照,但没有证明缓存错误的根因。

这类问题的处理原则是:记录现象、保留复现条件、不要把一个环境的成功当成所有环境的保证。文档里也明确建议,迁移后注销重登再使用,以刷新旧登记。

测试:把“感觉能打”变成数字

项目的验证分两层。

离线测试共 31 项,包括 23 个布局测试和 8 个定点修改保护测试。覆盖生成 XML 的普通层、Shift、Option、Caps、Command、Control、取消与回退行为,以及参考快照的校验和。

其中死键转换是穷举核对的:

  • E2 参考表共 1,509 条转换,其中 1,507 条可由物理按键路径到达。
  • E1 使用独立参考表,1,508 条转换全部可由 ISO 键码路径到达。
  • 两版各有 92 个可到达死键状态,包括中间状态。

E2 有两条记录无法在已有待定状态中直接从物理按键输入:02DA+00D7(ring 之后输入 ×)和 02DF+200A(扩展选择之后输入 hair space)。生成文件保留了相应字符动作并做字符级测试,但没有为了“凑覆盖率”发明额外快捷键。

原生验证则直接调用 macOS 的 UCKeyTranslate:

布局键盘类型原生检查结果
E2 ANSI40、915,024 项0 失败
E1 ISO38、415,026 项0 失败

两版的主语言都被系统确认为 de。验证工具在运行前后检查当前输入源是否改变,确保它没有偷偷切换输入法。

38/41 是 SDK 定义的便携 USB ISO / 第三方 ISO 类型,属于系统 API 模拟,不是实体 ISO 键盘测试。文档把这一点写得很清楚,没有把模拟验证说成硬件验证。

和 Rime / 鼠须管的配合

德语布局和中文输入法同时使用时,还会遇到一个很具体的问题:鼠须管 1.1.2 用 charactersIgnoringModifiers 转换按键。E2 的 Option+8/9 在系统上输出括号,但传给 Rime 的是 Alt+8/9;裸 ^ 是死键,模拟事件的字符为空,Rime 配置无法凭空捕获。

项目的 RIME-E2.md 记录了一个范围很小的补丁:把 Option+数字、Shift+^ 等事件转换为中文标点入口,同时保留度数符号和 ASCII 脱字符;key_binder 放在 recognizer 之前,避免被反引号反查先截获。

验证是在独立临时目录里用本机 librime 完成的,不碰真实用户词库:修改前复现失败,修改后 62 项检查通过,覆盖全角/半角、空输入/已有候选、英文模式和西文标点模式。

这部分的定位也很克制:它只适配中文标点,不承诺修复英文模式下原有的 Option 行为。

边界:哪些话不能说

这个项目在文档里花了不少篇幅写“尚未验证”:

  • 没有逐键按压实体键盘核对印字位置。
  • 没有覆盖各编辑器、终端、浏览器及其自定义快捷键。
  • 登录窗口、FileVault、密码对话框不保证可用。
  • 没有在 Intel Mac 或较旧 macOS 上验证。
  • ISO/JIS 实体键盘和外接数字键盘未做人工测试。
  • 参考实现和用户布局图不等于 DIN 标准全文,项目不宣称通过标准认证。

这些限制不影响它作为一个可用的原生布局,但决定了文档不能写成“全平台完美支持”。对键盘布局这种底层输入组件来说,诚实列出边界比堆功能列表更有价值。

试用

如果只想使用成品:

mkdir -p "$HOME/Library/Keyboard Layouts"
cp -R dist/GermanE2.bundle "$HOME/Library/Keyboard Layouts/"
cp -R dist/GermanE1.bundle "$HOME/Library/Keyboard Layouts/"   # 需要 ISO 备用版时

然后到 系统设置 → 键盘 → 文本输入 → 编辑 → + → 德语,选择 German E2 (ANSI) 或 German E1 (ISO)。

如果要从源码构建:

python3 tools/build_layout.py
python3 -m unittest discover -s tests -v
swift tools/verify_macos.swift dist/native-cases.json
swift tools/verify_macos.swift dist/native-cases-E1.json

构建只生成 dist/ 下的文件,不会安装、注册或切换输入源。

项目地址:github.com/fluviusmagnus/de-tastatur。代码、构建脚本、测试和生成的布局文件采用 MIT 许可证;references/ 下的第三方参考材料有单独的许可说明。

最后

这个项目最有意思的地方,不是“用 Python 生成了一个 XML”,而是它把一件看起来只能靠手工试错的事情,拆成了可复现的参考表、可穷举的状态机、可自动化的原生验证,以及一份不夸大的限制清单。

原生键盘布局是一个老技术,老到很多细节只能靠读系统头文件、看参考实现和反复加载验证来确认。但也正因为老,一旦做对,它就会安静地待在系统里,不占进程、不要权限,也不打扰任何人。

这大概就是一个输入源最理想的存在方式。

标签: none

赞 (0)

添加新评论

  • 上一篇: 0047
  • 下一篇: 没有了