鸿蒙 PC Markdown 编辑器无障碍国际化工程:焦点、系统字体与双向文本
鸿蒙 PC Markdown 编辑器无障碍国际化工程焦点、系统字体与双向文本Markdown 编辑器的无障碍不能只理解成“给几个按钮加名称”。在鸿蒙 PC 上一条真实写作路径会跨过 ArkUI 活动栏、文件树、标签栏、原生工具栏、ArkWeb、CodeMirror、搜索面板和模态命令。如果其中任何一层吞掉 Tab、把焦点画成不可见、在系统字体放大后遮住按钮用户就无法稳定完成任务。国际化也不只是把中文字符串换成英文日期、数字、文件大小、超长文件名、阿拉伯文、希伯来文、组合字符和 Emoji 都会改变桌面界面的实际布局与文本方向。本文以 OhMarkdown 的无障碍与国际化实现为例说明如何在鸿蒙 PC 混合编辑器里建立一套可验证的工程规则。仓库地址是 https://gitcode.com/VON-/codex_md_oh对应功能提交是75f26f6。代码由 ArkUI 原生工作台、ArkWeb 和 CodeMirror 组成验证环境是 DevEco Studio MateBook Pro 2in1 模拟器HarmonyOS 6.1.1、API 24。本文会明确区分模拟器结论和仍需真机补齐的读屏、物理键盘与 2.0 字体测试。先把无障碍拆成可失败的系统如果需求只写“支持无障碍”实现很容易退化成零散修补。更有效的拆法是按失败方式建模焦点是否可达焦点是否可见焦点是否能离开控件是否有名称和状态文字放大后是否仍可操作双向文本是否改变源码区域格式是否被硬编码。每一类都有不同的责任层。ArkUI 负责系统窗口、活动栏、文件树、标签、设置、系统字体和原生可访问语义ArkWeb 负责网页焦点边界CodeMirror 负责编辑器键盘行为和正文事实来源CSS 负责可见焦点和段落方向本地化服务负责日期、数字和文件大小。把所有问题都塞进 WorkspaceShell或者全部交给 Web都无法得到稳定边界。本次实现使用以下退出规则ArkWeb 获得焦点后必须能用 Tab 回到 ArkUI不能形成键盘陷阱。模态面板打开时Tab 不能穿到遮罩后的编辑区Escape 必须归还编辑器。文件、搜索结果、大纲和标签除了鼠标点击还必须接受 Enter 或 Space。纯图标按钮必须有可访问名称陌生图标必须有悬浮提示。选择状态不能只靠绿色背景必须暴露 selected 或 checked。系统最大字体下核心命令不能重叠、不可达或只剩无法辨认的省略号。RTL、CJK、Emoji 和组合字符在编辑与预览之间往返时Markdown 正文必须完全不变。这些规则可以进入自动化、控件树和模拟器任务而不是停留在主观“看起来还行”。字体缩放从应用配置开始ArkUI 控件想跟随系统字体应用必须先在 AppScope 声明配置。OhMarkdown 没有在页面里写一组私有字号倍率而是让系统成为用户偏好的来源同时把当前产品经过设计的最大支持比例限制为 2.0{configuration:{fontSizeScale:followSystem,fontSizeMaxScale:2}}然后在AppScope/app.json5引用资源{ app: { bundleName: com.example.ohmarkdown, vendor: example, versionCode: 1000000, versionName: 1.0.0, buildVersion: 1, icon: $media:layered_image, label: $string:app_name, configuration: $profile:configuration } }这里的上限不是用来拒绝无障碍而是诚实表达当前验证边界。设置成 3.2 却没有任何页面在 3.2 下通过只会把未验证布局交给用户。项目当前目标是先把 2.0 作为明确支持上限并继续在实际提供该档位的鸿蒙 PC 设备上补完整矩阵。不把配置值误当运行时实际比例应用配置声明的是策略真正布局需要知道当前设备实际采用了多少字体比例。模拟器系统设置的滑块移动到最大后实际回调并不是 1.5 或 2.0而是约 1.45。若断点写死在 1.5界面已经明显放大响应式布局却永远不触发。OhMarkdown 没有依赖不可观察的假设而是利用同一个 UIContext 下 1 vp 和 1 fp 的像素值计算比例exportconstMIN_SUPPORTED_FONT_SCALE:number1;exportconstMAX_SUPPORTED_FONT_SCALE:number2;exportconstEXPANDED_TOOLBAR_FONT_SCALE:number1.4;exportfunctionnormalizeFontScale(value:number):number{if(!Number.isFinite(value)){returnMIN_SUPPORTED_FONT_SCALE;}returnMath.max(MIN_SUPPORTED_FONT_SCALE,Math.min(MAX_SUPPORTED_FONT_SCALE,value));}exportfunctionresolveFontScaleFromPixels(vpPixels:number,fpPixels:number):number{if(!Number.isFinite(vpPixels)||!Number.isFinite(fpPixels)||vpPixels0){returnMIN_SUPPORTED_FONT_SCALE;}returnnormalizeFontScale(fpPixels/vpPixels);}exportfunctionshouldExpandToolbar(value:number):boolean{returnnormalizeFontScale(value)EXPANDED_TOOLBAR_FONT_SCALE;}组件只负责读取上下文并决定是否使用扩展布局privateusesExpandedToolbar():boolean{try{constuiContextthis.getUIContext();returnshouldExpandToolbar(resolveFontScaleFromPixels(uiContext.vp2px(1),uiContext.fp2px(1)));}catch(_){returnfalse;}}这段代码的价值不在数学复杂度而在于把设备差异变成可测试输入。单元测试覆盖非有限值、低于 1、高于 2、像素分母为零和 1.4 断点模拟器再验证真实 1.45 会走到扩展布局。纯函数和设备事实互相补足。工具栏的目标是稳定可达不是强行塞满PC 编辑器顶部往往同时包含多标签、新建标签、打开、保存、源码、即时、分栏、预览、新建窗口和侧栏切换。普通字体下可以横向排列字体放大后继续等比例压缩会出现三类问题文字省略、点击区重叠、编辑区域被挤到异常宽度。OhMarkdown 的处理不是把字体缩回去而是提高工具栏高度、给内容一个稳定最小宽度并允许整个工具栏横向滚动Scroll(){Row(){// 标签、打开、保存、四种模式与图标命令}.height(100%).width(100%).constraintSize({minWidth:this.usesExpandedToolbar()?1100:0})}.width(100%).height(this.usesExpandedToolbar()?52:42).scrollable(ScrollDirection.Horizontal).scrollBar(this.usesExpandedToolbar()?BarState.Auto:BarState.Off)横向滚动是一项明确取舍它不会让所有命令始终同屏但能保证每个命令保持可读、可聚焦和稳定尺寸。对于重复写作工具重叠和随机缩放比“需要滚动到右侧”更糟。真机阶段还要验证触控板横向手势、Shift滚轮以及键盘焦点移动到屏外按钮时系统能否自动把它滚入视口。图标和文字也不能采用同一缩放策略。SymbolGlyph 是工具形状不是正文随系统字体无限放大会改变工具栏几何。实现对图标设置maxFontScale(1)对按钮文字保留maxFontScale(2)。这样用户需要阅读的命令会放大熟悉的工具图标维持稳定点击区。侧栏中的分段控件需要改变方向搜索面板原本有“当前文档、工作区、快速打开”三个横向分段。最大字体时如果只增加高度中文仍可能勉强可见English 却容易省略。设置里的语言、自动保存、图片目录和拖放模式也有同样问题。扩展布局直接改变方向而不是尝试精确计算每个翻译字符串的宽度if(this.usesExpandedToolbar()){Column({space:2}){this.searchModeButton($r(app.string.search_current_document),SearchPanelMode.DOCUMENT,100%,true)this.searchModeButton($r(app.string.search_workspace),SearchPanelMode.WORKSPACE,100%,true)this.searchModeButton($r(app.string.quick_open),SearchPanelMode.QUICK_OPEN,100%,true)}.width(100%).height(128)}else{Row(){// 三个 33.33% 按钮}.height(32)}替换与全部替换同样在扩展字体下改成两个全宽按钮。设置面板自身是 Scroll因此纵向增长不会让后面的资源设置永久不可达。这里使用单一断点不为每个分组发明独立阈值避免桌面界面在相邻字号间反复跳动。一次真实截图发现了自动化没有覆盖的省略最大字体的中文设置面板通过后如果直接宣布完成会漏掉 English 的长命令。第一次切换 English 后Keyboard Shortcuts和Export Preview PNG仍显示省略号。按钮宽度并不小真正消耗空间的是 ArkUI Button 默认水平内边距。最终修复没有粗暴扩大整个侧栏而是为这组全宽命令显式设置 4 vp 水平内边距Button($r(app.string.keyboard_shortcuts_action)).type(ButtonType.Normal).width(100%).height(34).padding({left:4,right:4}).borderRadius(5).fontSize(13).maxFontScale(2)相同规则应用于命令面板、HTML、PDF、PNG 和 Markdown 分享按钮。重新构建、安装后两个长英文命令完整显示中文布局没有退化。这个过程说明字符串资源齐全不等于国际化完成。每种语言都要进入真实布局而且测试不能只检查元素存在必须检查用户看见的最终结果。截图不是装饰它在这里发现了明确产品缺陷。ArkUI 行项目要拥有完整键盘语义文件树、搜索结果、大纲和标签过去主要依赖.onClick()。鼠标路径正常不代表键盘路径存在。实现为这些行项目补充焦点与按钮角色并让 Enter/Space 调用同一个动作privatehandleFocusableActionKey(event:KeyEvent,action:()void):boolean{if(event.type!KeyType.Down||(event.keyCode!KeyCode.KEYCODE_ENTERevent.keyCode!KeyCode.KEYCODE_SPACE)){returnfalse;}action();returntrue;}文件行的接入方式如下.focusable(true).tabStop(true).accessibilityRole(AccessibilityRoleType.BUTTON).accessibilityText(entry.name).accessibilitySelected(this.documentUrientry.uri).onKeyEvent((event:KeyEvent):booleanthis.handleFocusableActionKey(event,(){if(entry.isDirectory){this.toggleWorkspaceDirectory(entry);}else{this.requestOpenWorkspaceDocument(entry);}}))标签还会把脏状态作为描述暴露当前标签不只显示绿色下划线读屏语义还能得到“已修改”或“已保存”。视觉省略的超长文件名则继续把完整session.name作为可访问文本稳定标签宽度和完整名称并不冲突。选择状态也需要显式表达。活动栏使用accessibilitySelected同步滚动和复选框使用accessibilityChecked语言、搜索模式、自动保存与资源规则使用 selected。这样系统辅助技术不必从背景色猜状态。图标按钮必须有名字也要照顾普通鼠标用户活动栏的文件、搜索、大纲、历史和更多操作都是图标。仅把图标画清楚仍不够系统辅助能力需要名称第一次使用的鼠标用户也需要提示。统一 Builder 同时设置可访问文本和悬浮提示privateiconButton(icon:Resource,label:Resource,action:()void){Button(){SymbolGlyph(icon).fontSize(18).maxFontScale(1).fontColor([$r(app.color.workspace_icon)])}.type(ButtonType.Normal).width(32).height(32).accessibilityText(label).bindTips(label,{appearingTime:500,disappearingTime:100}).onClick(action)}名称来自 ArkUI 字符串资源所以应用切换简体中文或 English 后辅助名称和提示使用同一语言来源。它避免界面写着“保存”提示却仍是英文也避免代码里散落硬编码字符串。ArkWeb 的难点不是进入焦点而是离开焦点混合编辑器很容易出现键盘陷阱Tab 在网页内部循环用户无法回到原生工具栏或者焦点虽然离开页面没有任何可见提示。OhMarkdown 分两层解决。第一层是 Web 内部模态面板。命令面板、快捷键设置、链接助手和冲突比较打开后焦点只能在可见、启用、tabIndex 0的元素间循环constMODAL_FOCUSABLE_SELECTORbutton:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex];functiongetModalFocusableElements(dialog:HTMLElement):ArrayHTMLElement{returnArray.from(dialog.querySelectorAllHTMLElement(MODAL_FOCUSABLE_SELECTOR)).filter((element)element.tabIndex0!element.hasAttribute(hidden)element.getClientRects().length0);}functiontrapModalFocus(dialog:HTMLElement,event:KeyboardEvent):void{if(event.key!Tab||dialog.hidden)return;constfocusablegetModalFocusableElements(dialog);if(focusable.length0){event.preventDefault();dialog.focus();return;}constfirstfocusable[0];constlastfocusable[focusable.length-1];if(event.shiftKeydocument.activeElementfirst){event.preventDefault();last.focus();}elseif(!event.shiftKeydocument.activeElementlast){event.preventDefault();first.focus();}}命令和链接候选采用方向键管理活动项因此它们被设置为tabIndex-1避免一百条候选把 Tab 顺序扩张成一条长隧道。Escape 关闭面板后继续调用编辑器focus()用户能回到原任务。第二层是 ArkWeb 和 ArkUI 的边界。模拟器把源码编辑器聚焦后注入 HarmonyOSKEYCODE_TAB2049前景窗口的文档活动按钮出现系统蓝色焦点环。这证明 Web 没有拦截末端 Tab系统能够继续进入 ArkUI。HDC 注入不是物理键盘结论但它比只阅读代码更接近真实系统路由。真机还要执行打开、编辑、查找、保存、切标签和关闭的完整纯键盘任务并检查输入法候选窗、组合键和触控板是否改变焦点。焦点可见要覆盖深色和浅色主题浏览器默认 outline 容易被 reset 样式取消CodeMirror 过去也显式使用outline: none。本次改为统一的主题变量:root{--focus-ring:#087a63;}.cm-editor.cm-focused{outline:2px solidvar(--focus-ring);outline-offset:-2px;}:where(button, input, select, textarea, [tabindex]):focus-visible{outline:2px solidvar(--focus-ring)!important;outline-offset:2px!important;}body[data-themedark]{--focus-ring:#63cdb5;}浅色用较深的绿色深色用更亮的青绿色目的不是品牌装饰而是保证焦点和相邻背景有明确对比。CodeMirror 使用负 offset 把轮廓收在编辑器边界内避免改变布局尺寸普通控件使用正 offset让焦点不被控件边框吞没。双向文本处理必须服从源码保真阿拉伯文和希伯来文并不会自动要求整个应用镜像。Markdown 文档可能在一个页面里同时出现英文标记、中文说明、阿拉伯文标题、希伯来文段落、Emoji 和代码。编辑器首要规则是保存用户输入的准确序列不根据视觉方向改写正文。CSS 使用unicode-bidi: plaintext让每个文本块按自身首个强方向字符决定显示方向.cm-line, #preview :is(p, li, blockquote, h1, h2, h3, h4, h5, h6, td, th), .conflict-line__content{unicode-bidi:plaintext;}#preview :is(p, li, blockquote, h1, h2, h3, h4, h5, h6, td, th), .command-palette__title, .link-assistant__label, .link-assistant__target{overflow-wrap:anywhere;}自动化语料为# العربية مع Markdown שלום עולם鸿蒙 PCé 与 Emoji 测试不仅检查预览包含这些字还调用getDocument()与原字符串做完全相等比较。组合字符e\u0301不能被测试框架偷偷改成预组字符Emoji 只需确认正文和预览保留不对视觉宽度做脆弱像素假设。CodeMirror 编辑行与预览段落的计算样式都必须返回unicode-bidi: plaintext。区域格式不要继续手写字符串版本历史原来用getFullYear()、padStart()和固定YYYY-MM-DD HH:mm:ss拼接时间文件大小用toFixed(1)固定小数点。这在中文开发机上看起来正常但不会自动适配区域设置的数字分隔符、日期顺序和时制。本地化服务改用标准IntlfunctionresolveFormattingLocale(locale?:string):string{returnlocale??i18n.System.getSystemLocaleInstance().toString();}exportfunctionformatLocalizedDateTime(value:number,locale?:string):string{returnnewIntl.DateTimeFormat(resolveFormattingLocale(locale),{year:numeric,month:2-digit,day:2-digit,hour:2-digit,minute:2-digit,second:2-digit}).format(newDate(value));}exportfunctionformatLocalizedNumber(value:number,maximumFractionDigits:number,locale?:string):string{returnnewIntl.NumberFormat(resolveFormattingLocale(locale),{minimumFractionDigits:0,maximumFractionDigits}).format(value);}文件大小仍使用 B、KiB、MiB 这一组稳定二进制单位但数值交给区域格式。界面语言和地区不是同一个概念用户可以选择 English同时保留中文地区的日期和数字习惯。因此默认读取系统区域而测试允许显式传入zh-CN或en-US得到确定结果。自动化要覆盖行为不要只数属性Web 新增两项核心测试。模态测试打开命令面板确认查询框获得焦点、候选项不进入 Tab 顺序、首尾 Tab 循环成立、焦点轮廓为 2 px solidEscape 后 CodeMirror 恢复焦点。然后对快捷键设置重复首尾循环。RTL 测试设置完整正文、切换预览、比较getDocument()分别检查阿拉伯文、希伯来文、中文、组合字符和 Emoji再读取编辑行与预览段落的unicodeBidi计算样式。ArkTS 纯函数测试覆盖expect(normalizeFontScale(Number.NaN)).assertEqual(1);expect(normalizeFontScale(0.8)).assertEqual(1);expect(normalizeFontScale(1.49)).assertEqual(1.49);expect(normalizeFontScale(2.4)).assertEqual(2);expect(resolveFontScaleFromPixels(2,3.5)).assertEqual(1.75);expect(shouldExpandToolbar(1.39)).assertFalse();expect(shouldExpandToolbar(1.4)).assertTrue();expect(formatLocalizedNumber(1234.5,1,en-US)).assertEqual(1,234.5);最终结果如下Playwright59/59。Debug HAP构建成功。ArkTSUnitTestBuild构建成功。ohosTest HAP构建成功。MateBook Pro 2in1 模拟器 ohosTest15/15Failure 0、Error 0总耗时 1963 ms。生产单 HTML7,690,528 字节保持单文件、零外部子资源。功能提交75f26f6已同步到 GitCodemain。测试过程保留了一个有价值的失败最大字体 English 首次截图出现长按钮省略。它没有被当成“系统默认行为”忽略而是进入修复、重新构建、重新安装和重新截图闭环。质量记录应该保留这种过程因为它说明设备验收确实在发现问题。模拟器可以证明什么不能证明什么模拟器能够证明当前 HAP 在 HarmonyOS 6.1.1 / API 24 上运行系统字体设置能传给应用实际 1.45 触发扩展布局中文与 English 可以切换RTL 预览不重叠Tab 能从 ArkWeb 进入 ArkUI最终原生测试通过。它还可以提供 3120 x 2080 的真实应用截图而不是设计稿。模拟器不能证明真实屏幕阅读器的朗读内容和顺序。控件树里有 name、role、selected 和 checked只能说明应用提供了语义不说明某个真机系统版本一定以预期方式朗读。HDC 的键码注入也不能替代物理键盘的按键扫描、组合键竞争、输入法候选窗和触控板焦点行为。当前模拟器字体滑块最大实际为 1.45因此不能声称已经完成 2.0 视觉矩阵。应用支持上限是 2.0下一步需要在提供 200% 字体的目标设备上逐页验证。真实读屏、物理键盘、2.0 字体和触控板都是 RC 闸门不会因为工程小阶段完成而自动消失。对鸿蒙 PC 产品竞争力的意义无障碍与国际化不是独立的合规附加项它们直接改善所有桌面用户的稳定性。可见焦点让快捷操作更可预测明确 selected/checked 让状态不再只依赖颜色大字体响应式布局减少窗口缩小时的重叠区域格式避免专业历史记录看起来像硬编码原型RTL 和组合字符保真则保护 Markdown 作为跨语言纯文本格式的根本价值。与“功能很多但边界模糊”的编辑器相比OhMarkdown 的优势目标是可解释正文始终是唯一事实来源显示方向不反写源码字体缩放不偷偷缩小用户文字Web 模态不困住焦点原生和网页之间的键盘路由有设备证据。当前这些能力达到模拟器证据完整的工程水平但没有竞品统一任务和真机数据前不对外宣称全面领先。真正达到可发布的 4 分需要同一台鸿蒙 PC 真机、同一字体比例、同一组长文件名与 RTL 文档让 OhMarkdown 和对标产品分别完成打开、查找、编辑、保存、切标签和导出任务记录完成率、按键数、焦点丢失、截断和总耗时。只有数据能把“我们重视无障碍”变成产品优势。结语鸿蒙 PC Markdown 编辑器的无障碍工程本质上是一套跨 ArkUI、ArkWeb、CodeMirror、CSS、系统配置和本地化服务的状态协议。系统字体决定真实排版压力焦点协议决定键盘能否完成任务语义决定辅助技术能否理解控件Unicode 规则决定跨语言正文能否保持原样。OhMarkdown 本次实现建立了可继续扩展的基线应用跟随系统字体并限制已支持范围实际比例触发响应式方向变化顶部命令在放大后保持可滚动和可达核心 ArkUI 行项目具备键盘动作与语义Web 模态焦点可循环、可退出RTL、CJK、Emoji 与组合字符不改变正文日期和数字遵循区域设置。更重要的是报告没有把模拟器结果包装成真机读屏结论。接下来的性能与兼容性阶段会把这些规则带入 CommonMark/GFM、长文档、图片、公式、图表、多标签和多窗口压力矩阵。无障碍不是做完一次就封存的页面检查而应该成为每个新增功能必须重新通过的桌面质量门禁。