在 macOS 中强制使用 E1/E2 德语键盘并输入中文
注意:项目真实存在,本文完全 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 E2 | German E2 (ANSI) | 常见 ANSI 键盘 |
| German E1 | German E1 (ISO) | 带左 Shift 额外键的 ISO 键盘 |
两个 bundle 都在 Info.plist 中通过 KLInfo_* 声明 TISIntendedLanguage=de。这一步很关键:只有声明了主语言,系统才会把布局归入“德语”分类,而不是丢进“其他”。
为什么选择原生输入源
原生 .keylayout 的问题很明显:格式老、文档少、调试工具不友好,出错时系统往往只给你一个“布局不生效”。
但它的优点也同样明显:
- 没有常驻进程,没有事件拦截,不依赖辅助功能权限。
- 系统级生效,应用看到的是正常的键盘布局,而不是被改写的按键事件。
- 可以和输入法、Caps Lock、Command 快捷键等系统行为正确协作。
- 安装方式就是一个 bundle 复制到
~/Library/Keyboard Layouts/,卸载就是删掉它。
对于“我只是想要一个德语键盘布局”这种需求,原生方案更干净。代价是,你得自己把布局文件生成对,并且用系统真正加载后的结果来验证。
技术路线:从 Windows 表到 Apple keylayout
项目的生成器是纯 Python,不依赖第三方库。输入是固定版本的参考快照:
- Linux
xkeyboard-config2.48 的de(e1)/de(e2),用于物理键位参考。 - Windows
KBDGRE1.DLL/KBDGRE2.DLL的公开布局表,用于字符、死键和扩展选择状态机。
参考文件全部带有 SHA-256 校验和。生成前先核对,避免“参考表悄悄变了,输出也跟着变”。
生成流程大致是:
- 解析 Windows 表:读取
VK_TO_WCHAR_TABLE和PhysicalKeys,把 PC 扫描码映射到 macOS 虚拟键码。 - 建立 10 套 keymap:普通、Shift、Caps、Shift+Caps、Option、Shift+Option,以及 Command/Control 的快捷键变体。
- 移植死键状态机:Windows 的
DEADKEY表记录的是“当前重音状态 + 输入字符 → 结果字符/下一状态”。生成器把它翻译成 keylayout 的action、state和when。 - 处理终止符:未完成的死键在遇到普通字符、Escape 或退格时如何收尾,需要和参考实现一致。
- 渲染 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 输出时,丢弃了 &、<、> 这类命名实体。普通 XML 解析器完全接受这些写法,所以离线测试发现不了。
修复方式是改用数值引用:&、<、>。同时增加离线回归测试,防止以后又改回命名实体。
这件事的教训很直接:能通过 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 ANSI | 40、91 | 5,024 项 | 0 失败 |
| E1 ISO | 38、41 | 5,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”,而是它把一件看起来只能靠手工试错的事情,拆成了可复现的参考表、可穷举的状态机、可自动化的原生验证,以及一份不夸大的限制清单。
原生键盘布局是一个老技术,老到很多细节只能靠读系统头文件、看参考实现和反复加载验证来确认。但也正因为老,一旦做对,它就会安静地待在系统里,不占进程、不要权限,也不打扰任何人。
这大概就是一个输入源最理想的存在方式。