diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 204a4772..a26a585a 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -42,7 +42,7 @@ jobs: "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke - run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_terminal_modes_test --parallel 2 + run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test --parallel 2 - name: Record complete path-free runtime manifests run: | python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-bin/Release --executable termforge_qt_shell.exe @@ -53,6 +53,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-unicode-bin/Release --executable qt_unicode_input_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-unicode-render-bin/Release --executable qt_unicode_render_test.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-modes-bin/Release --executable qt_terminal_modes_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Preserve runtime file identities without binaries @@ -67,9 +69,10 @@ jobs: build/qt-cpp/qt-smoke-bin/Release/runtime-manifest.json build/qt-cpp/qt-resize-bin/Release/runtime-manifest.json build/qt-cpp/qt-unicode-bin/Release/runtime-manifest.json + build/qt-cpp/qt-unicode-render-bin/Release/runtime-manifest.json build/qt-cpp/qt-modes-bin/Release/runtime-manifest.json - name: Run Qt offscreen smoke in its owned Job - run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_terminal_modes(_retained)?)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?)$' --no-tests=error --verbose # Keep this configuration independent from the default/system-backend and # Alacritty reference jobs. SDKs, runtime folders and raw logs are not # publication artifacts; this job does not produce a release package. diff --git a/CMakeLists.txt b/CMakeLists.txt index e765316c..20d8bee0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -301,6 +301,9 @@ if(WIN32) add_executable(shell_queue_probe experiments/shell/queue_probe.cpp) add_executable(shell_size_client experiments/shell/size_client.cpp) add_executable(shell_input_client experiments/shell/input_client.cpp) + add_executable(shell_unicode_echo_client experiments/shell/unicode_echo_client.cpp) + termforge_warnings(shell_unicode_echo_client) + target_compile_definitions(shell_unicode_echo_client PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN UNICODE _UNICODE) add_executable(shell_text_client experiments/shell/text_client.cpp) termforge_warnings(shell_text_client) target_compile_definitions(shell_text_client PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN UNICODE _UNICODE) @@ -712,6 +715,23 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_unicode_input PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") + add_executable(qt_unicode_render_test src/qt/unicode_render_test.cpp) + set_target_properties(qt_unicode_render_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-unicode-render-bin/$") + termforge_qt_entry(qt_unicode_render_test) + target_link_libraries(qt_unicode_render_test PRIVATE Qt6::Test) + add_dependencies(qt_unicode_render_test shell_unicode_echo_client) + add_custom_command(TARGET qt_unicode_render_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" "$/" + VERBATIM) + add_test(NAME qt_unicode_render COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_unicode_render PROPERTIES TIMEOUT 35 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") add_executable(qt_cpp_resize_smoke src/qt/resize_smoke.cpp) set_target_properties(qt_cpp_resize_smoke PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-resize-bin/$") diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 99e7ed39..77f8c67c 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -18,6 +18,7 @@ | 原版 resize 后寻址与自主核心字面诊断 | [独立直接协议样本](../research/ui/results/2026-11-02-resize-address-reference/README.md)、[默认裁剪路径的失败诊断](../experiments/core/results/2026-11-02-resize-address/README.md) | 四例自然追加、CR、LF、CUP 保留动作前观测,文档、DSR、显示坐标分别核对;第五例 EL1 保留原审计失败。默认 `Screen::resize` 历史诊断仅 16/64 阶段通过、退出 1;当时 `EXCLUDE_FROM_ALL`,现参与默认构建但 CTest 只注册另行验证的显式保留入口。旧红不设 `WILL_FAIL`,常规 CI 或另一入口通过不能豁免默认路径差异 | | 缩窄后跨前方显示重排的 CUP/DSR 寻址(#27/#29 显式入口) | [单例原版四阶段严格记录](../research/ui/results/2026-11-03-reflow-addressing-reference/README.md)、[核心字面回放与身份](../experiments/core/results/2026-11-03-reflow-addressing/README.md):`CSI 8;2HQ` 写入文档行 6(保留的缩放前源片段行)而非重排行 5,DSR `8;3`、caret 同口径;窄屏矩形 (155,144) 恢复 (155,126) 证实显示页独立 +1 行重排 | 单例固定 ASCII、`91×20→61×20→91×20`、一个动作、最大一次运行;只补既有样本无法区分的行映射,不含备用屏、高度、Unicode、历史、样式、Qt/固定 ConPTY 或性能;默认 `Screen::resize`/Qt 未切换,#27/#29 不关闭 | | 正式 Qt / 固定 ConPTY 接入保留事务、显示投影与显示光标 | [契约、身份与差异](../experiments/shell/results/2026-11-03-qt-retained-display/README.md):可达真实 child→固定 ConPTY→core→widget;新 `qt_terminal_modes_retained` 12/12 检查通过,只缩宽后显示页第 8 行与原生重排逐字节一致,B/C/D 保留源位,命中经 layout revision 回到源格;Qt 正式路径 CTest 5/5,核心 36/36(含 `display_cursor_core`) | 旧六阶段 `--resize` 仍 exit 1 且失败信息不变,保留为原生重排网格与保留行合同的坐标差;同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据;不覆盖真实呈现/DPI/历史/Find/性能,#27/#29 不关闭 | +| 正式 Qt / 固定 ConPTY 固定 Unicode 样本贯通呈现与独立接收(#30 切片) | [链路、样本与实测](../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md):新增 `qt_unicode_render`,5 条固定样本经公开 Qt 事件→真实 child `ReadConsoleW` 精确接收→同字节 CR→CRLF 回显→核心解析→`TerminalWidget` 离屏抓图;receiver/echo SHA-256 与源字节逐字节一致,CJK 实测 fallback `Alibaba PuHuiTi`、emoji `Segoe UI Emoji`(advance 21>span 18 但无裁剪参考墨迹 3..17 未越格),结合标 `marks=1`,第二条空会话 0 字节且无样本文字;Qt 正式路径 6/6 | U+200D/U+FE0F 仍被 recovery parser 替换为 U+FFFD(只记录,不批准新聚类/宽度策略);offscreen + 宿主 fallback 依赖、`presented_done=null`,不覆盖物理呈现/原生 IME/真实 DPI/完整字素或 #30/#155 验收 | | 隐藏主屏纯高度变化的正文保留(#27 工程合同) | [RED/GREEN、命令与边界](../experiments/core/results/2026-11-04-hidden-height-retention/README.md):修复前 `resize_hidden_height_test` 证明隐藏 live/历史被按未变宽度裁剪并丢失保留尾;`resize_preserving` 改为只在宽度实际变化时适配隐藏帧后,三场景通过并注册 `retained_resize_hidden_height_core`;组合回放 20/20、事务检查不变,正式 Qt CTest 5/5 | 工程不变量;原版对照见下一行。未覆盖备用屏后续输出、保存光标交叉、Unicode、历史淘汰、Qt 物理呈现/DPI 或性能;#27/#29 不关闭 | | 原版隐藏主屏高度的录制恢复与离线补充审计(#27/#29) | [补充审计、阶段观察与差异](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md):七阶段原版录制的严格审计失败保留;从仍可用的取证环境只读恢复完整输入后,离线补充审计通过(计划 `938992c4…`、参考 EXE `7b1053…`/4047 清单 `e459adf2…`、执行源清单 `51e80b93…`,0 次重跑),补充件 `4c0f28f2…` | 审计显示返回主屏后宽行按当前宽度裁剪为 61 空格 + `w`(62 字符)、DSR `19;7→14;7`,与 #311 的保留合同存在待用户确认的差异;本记录不修改实现、不重跑、不关闭 #27/#29;语义验收 false,备用屏阶段 API 文本不等于活动备用屏网格 | | 原版取证运行时与归档可恢复性盘点 | [环境类别、已核验身份、解除方式与条件](research/original-evidence-runtime-2026-11-04.md):原取证 QEMU 域仍可用,参考 EXE `7b1053…`、4047 文件清单 `e459adf2…`、隐藏高度计划 `938992c4…`、执行源清单 `51e80b93…` 与完整录制输入均在;宿主归档缺少 `app`/`profile`/`reference-source.json` 是此前 blocked 的原因 | 只发布路径类别、哈希与证明字段;私有录制/profile/原包不发布;解除方式只适用于原始输入完整且允许只读复核的记录,不外推到未核对归档;环境丢失后同类旧失败只能保持 blocked | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c1b52a5f..643127d1 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -55,7 +55,7 @@ Qt 构建显式准备并部署[固定微软 CRT 14.51.36247.0](../third_party/ms | 统一文字动作 | **限定验收**:共用目标/可用状态,实际终端右键/快捷键、原生 Find/rename 编辑、busy/closing/失效选区反馈及受控 DPI/zoom 保持;[最终系统审计](../experiments/shell/results/2026-10-24-r1-actions-accepted/validation.json)与[逐项对照](research/r1-action-comparison.md)覆盖 Find 实际 Ctrl+C/V、中文重选、默认 Return 和会话隔离 | 复杂 busy/closing 参考行为仍未知,采用明确项目策略;完整流程与八会话有限资源已由 #18 另验收,真实跨显示器及完整 WindTerm 等价未验收 | R1 | | 中文 IME 与键盘 | **限定验收**:基础按键/修饰键/UTF-16 与实际接收字节;[固定 ZWJ/keycap 输入](../experiments/shell/results/2026-10-26-unicode-keyboard/validation.json)在默认/候选各两轮精确接收。固定 Microsoft 拼音[产品场景](../research/ime/results/2026-10-24-product-ime-contexts/validation.json)覆盖两区域组合/提交/取消、候选、输出/resize/zoom/受控 DPI、隐藏/关闭、历史阅读和原生控件 | 限定 Windows Server 2025 固定拼音及已有场景;同次组合由八会话工作流另验收。固定字素绘制/选择已由 #39 限定完成,但所有输入法、应用键盘模式、完整 IME 和真实不同 DPI 跨屏仍未完成 | R1 / R2 | | VT 核心 | **实现中,未验收**:默认自研支持基础光标/擦除(含 ECH)、CR/LF/BS/TAB、有限 SGR(粗体/暗色/下划线/反显/删除线/隐藏/上划线)和有界恢复;新增编辑类 `ICH`/`DCH`(`CSI @`/`CSI P`)、`IL`/`DL`(`CSI L`/`CSI M`)、滚动区域 `DECSTBM`(`CSI Pt;Pb r`,归位与非法区域行为按参考实现)、`ED3`(`CSI 3J` 只清回滚)与 `RIS`(`ESC c`),`DSR 6n` 回复、`DECCKM`(`?1`)与 `DECTCEM`(`?25`)已接入 Win32 与 Qt 两个入口,均有[核心契约测试](../experiments/core/edit_csi_test.cpp)与[回复测试](../experiments/core/reply_test.cpp)。显式候选 `.4` 接入 `Term/Processor`、C++ 拥有值适配、稳定行元数据、每缓冲区 margin 与流式字素;[共同轨迹](../experiments/differential/results/2026-10-26-candidate-common-v6/summary.json)及[源 margin 合同](../experiments/shell/results/2026-10-26-margin-state/validation.json)为各自固定版本结果 | [#27](https://github.com/JavaZeroo/termforge/issues/27) 的 origin/wrap、模式/resize、插删与滚动区域交叉未闭合;[#41](https://github.com/JavaZeroo/termforge/issues/41) 系统 ConPTY 已丢失的 margin 不能由末端补回。ECH pending-wrap 差异见[限定合同](research/more-capture-ech.md);`DA1` 仅 strict 回放回复 VT102,recovery 产品路径不回复;`DA2` 与 `OSC 10/11` 回复未实现,原版未知仍不算匹配;`SGR 58` 等下划线颜色属性亦未实现;#36/#38、完整呈现和平台支持独立验收 | R2 / R4 | -| Unicode 与文字显示 | **实现中,未验收**:默认自研保留 Unicode 15。显式候选 `.4` 的 [#39 Unicode 17 流式字素合同](research/streaming-graphemes.md)已限定验收:固定 ZWJ/keycap 的单 lead/两列、光标、SGR/查询、原子选择复制、单列 resize 与有界无损分片;实际 ConPTY/DWrite 核对固定样本的单 glyph、字体和网格 | [#30](https://github.com/JavaZeroo/termforge/issues/30) 仍需完整输入/IME 交叉;所有脚本/整形、通用 fallback、所有原生重放、真彩色/更多属性、Powerline 和真实跨屏未验收。分段 helper 的规范数据通过不代表完整终端 Unicode 正确性;[#54](https://github.com/JavaZeroo/termforge/issues/54) 保留已观察的性能成本 | R2 / R4 | +| Unicode 与文字显示 | **实现中,未验收**:默认自研保留 Unicode 15。显式候选 `.4` 的 [#39 Unicode 17 流式字素合同](research/streaming-graphemes.md)已限定验收:固定 ZWJ/keycap 的单 lead/两列、光标、SGR/查询、原子选择复制、单列 resize 与有界无损分片;实际 ConPTY/DWrite 核对固定样本的单 glyph、字体和网格。正式 Qt/固定 ConPTY 路径已把固定 ASCII/CJK/补充平面/结合标样本贯通到独立 child 精确接收与离屏绘制,CJK/emoji 实际 fallback 面、advance 与无裁剪参考墨迹逐格记录([固定结果](../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md)) | [#30](https://github.com/JavaZeroo/termforge/issues/30) 仍需完整输入/IME 交叉;所有脚本/整形、通用 fallback、所有原生重放、真彩色/更多属性、Powerline 和真实跨屏未验收。Qt 呈现仅离屏固定样本,依赖宿主 fallback 面,U+200D/U+FE0F 仍被 recovery parser 替换为 U+FFFD(只记录,不批准新策略);分段 helper 的规范数据通过不代表完整终端 Unicode 正确性;[#54](https://github.com/JavaZeroo/termforge/issues/54) 保留已观察的性能成本 | R2 / R4 | | resize 与 reflow | **实现中,未验收**:默认仍按 ADR-0005 物理行裁剪;[显式保留列宽事务与显示投影](../experiments/core/results/2026-11-02-retained-columns/README.md)通过四例有限寻址诊断;[同 owner 宽高组合保留事务](../experiments/core/results/2026-11-02-retained-combined/README.md)把固定五阶段原版回放从 8/20 阶段修至 20/20,并注册核心门禁;正式 Qt 路径已接入该事务、独立显示页、显示光标与映射命中([只缩宽契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md) 12/12、Qt 正式 CTest 5/5),默认 resize 入口未替换;[隐藏主屏纯高度变化保留正文的工程合同](../experiments/core/results/2026-11-04-hidden-height-retention/README.md)已注册门禁,[原版七阶段补充审计](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md)显示返回主屏时按当前宽度裁剪隐藏行,与当前保留合同存在待确认差异。历史候选在[固定 ConPTY 重放](../experiments/core/results/2026-10-26-resize-replay/validation.json)与连续同步中保持行身份、Find/选区和有界历史,淘汰/清空不复活;候选 `.4` 另覆盖主/备用屏缩到单列 | [ADR-0010](adr/0010-bounded-resize-retention.md)已确认正文保留与显示投影方向,#27/#29 联合交叉仍未完成;[正式路径 B/CD 缩放诊断](../experiments/shell/results/2026-11-02-qt-terminal-modes/README.md)仍红且失败信息保留,已归类为原生重排网格与保留行坐标层差异,同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据;隐藏主屏高度的严格审计失败保留、离线补充审计已通过(单次七阶段,0 次重跑),返回时按当前宽度裁剪与当前保留合同的差异待用户确认。固定事务不等于完整 reflow 或 WindTerm 等价。[#156固定交错诊断](../experiments/shell/results/2026-10-29-resize-repaint-witness/README.md)见证默认核心在两种时序下选区失效,历史几何错配已有字节/消费网格证据;[#194](https://github.com/JavaZeroo/termforge/issues/194) 后续按用户接受未复现收口,未证明产品修复,重现时按该项条件重开。系统/实验 ConPTY、备用屏/滚动区、真实 DPI、呈现、性能及 #36/#38 分别验收;默认缩窄不可恢复 | R2 | | 本地 shell 选择 | **实现中,未验收**:`ShellLaunch` 分离可执行文件、参数、工作目录及 shell 命令差异;cmd、Windows PowerShell 5.1、PowerShell 7、显式 Ubuntu WSL 各有[两轮真实启动/目录/尺寸/正常退出证据](../experiments/shell/results/2026-10-24-r2-shell-lifecycle/validation.json),无效程序启动错误可见且可关闭后恢复 cmd。四 shell 测试已补 [WSL 原生尺寸程序返回后的新提示符同步](research/wsl-size-return.md),正常退出证据只在实际全部回收后写入;默认/system 的事件反例、原两轮及完整回归分别留证 | 仅声明上述固定入口和隔离目录;#80 原 hosted stage6 是否同因仍未知,#81 hosted 准备超时另行定位;任意 profile/参数、通用分屏/排序、全部运行时错误提示及管理员 shell 未完成 | R2;权限场景 R4 | | 常用 TUI | **实现中,未验收**:显式候选 + 系统 ConPTY 有固定 Vim 进入、编辑、60×12 resize、退出写盘、主屏恢复及光标回复的[产品证据](../experiments/core/CANDIDATE.md);More 尾部 ECH 与捕获端点有[限定修复](research/more-capture-ech.md) | [#28](https://github.com/JavaZeroo/termforge/issues/28) 的更多应用及完整输入/输出模式未完成;PR #50 实验后端的 GUI Vim [#52](https://github.com/JavaZeroo/termforge/issues/52) 仍有失败;[native 捕获目录复用](research/vim-fixture-isolation.md)已独立定位并修正,不能代替 GUI 或完整 TUI 验收。headless 比较与自有 DIB 不等于真实呈现,`presented_done=null` | R2 | diff --git a/experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md b/experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md new file mode 100644 index 00000000..5724f11b --- /dev/null +++ b/experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md @@ -0,0 +1,67 @@ +# 正式 Qt / 固定 ConPTY:固定 Unicode 样本贯通 Qt 呈现与独立 child 接收(#30 切片) + +本切片在**同一个正式 C++ / Qt Widgets / 随包固定 Microsoft ConPTY 构建**里,把一组有界固定 Unicode 样本从 Qt 输入一路贯通到中文:真实 child 的独立字节接收、child 回显、核心解析、`TerminalWidget` 实际绘制,以及离屏抓图后的字形/fallback/落格测量。它补的是当前**未覆盖**的呈现合同:既有 Qt 测试只核对 `750`/`427` 等 ASCII 墨迹或核心文字,`2026-10-26-unicode-renderer` 是历史 Win32/DirectWrite 路径,`qt_unicode_input` 只核对输入字节,均不核对本正式路径的 Unicode 绘制。 + +新增 CTest `qt_unicode_render`;不关闭 #30/#155/#7,不确认新的字素/宽度策略。 + +## 链路与固定样本 + +``` +公开 Qt 文本/焦点事件 → 既有 KeyboardEncoder → 固定 ConPTY → +child `ReadConsoleW` 精确接收(receiver 文件 = 独立字节 oracle)→ +child 以 CR→CRLF 回显同一字节(echo 文件 = 呈现源 oracle)→ +SessionModel parser → Screen cells → TerminalWidget 绘制 → 离屏 QImage 抓图 +``` + +- 样本集固定 5 条(字面 UTF-8,逐字节核对):ASCII `TermForge ASCII`;CJK `汉字终端`;补充平面 `🙂😀`(U+1F642/U+1F600);结合标 `e`+U+0301;仅记录项 `👩‍💻1️⃣`(U+1F469 U+200D U+1F4BB U+0031 U+FE0F U+20E3)。 +- 前 4 条是断言合同;ZWJ/keycap 只记录实际结果,不批准聚类或宽度策略(#39 未决)。 +- 第二条独立会话不接收任何样本,用于同时观察目标隔离:其 receiver 为 0 字节,屏幕不出现样本文字,child pid 与主会话不同。 + +## 实际结果(本次运行) + +环境:Windows 11 x64 build 26200,MSVC 19.42.34433.0,Qt 6.10.3 offscreen,随包固定 ConPTY 1.25.260930003,app-local CRT 14.51.36247.0,Python 3.12.10。`TerminalWidget` 80×24、单元 9×22 px;产品字体 Roboto Mono(SHA-256 `af0bff75…`)。 + +| 样本 | 独立 child 接收 / 回显 | 核心解析结果(实测) | 实际字形与 fallback | 绘制/落格 | +|---|---|---|---|---| +| ASCII | 61B 精确;receiver `32a5695a…`、echo `96ae6f16…` | 15 个 width=1 cell | `Roboto Mono`,advance 9 = 单元 9 | 每格有墨迹,墨迹落在各自 cell 内 | +| CJK | 同上一条链路,逐字节一致 | 4 个 width=2 cell + 4 个 trail | `Alibaba PuHuiTi`(宿主 fallback),advance 15 ≤ span 18 | 每格有墨迹(0..14 px),未越格 | +| 补充平面 | 逐字节一致 | 2 个 width=2 cell + 2 个 trail | `Segoe UI Emoji`,advance 21 > span 18 | 实测墨迹 3..17 px;同字体无裁剪参考渲染墨迹同为 3..17,**本样本未被可见裁切**;advance 超出仅登记 | +| 结合标 | 逐字节一致 | 1 个 cell,text=`e`+U+0301,width=1,marks=1 | `Roboto Mono`,advance 9 = 单元 9 | 墨迹 1..7 px,未越格 | +| ZWJ/keycap(仅记录) | 逐字节一致(含 U+200D/U+FE0F) | **U+200D、U+FE0F 被 recovery parser 替换为 U+FFFD**;解析为 `👩`+FFFD+`💻`+`1`+FFFD+U+20E3,5 个 lead cell | `Segoe UI Emoji`(两个 emoji)、`Roboto Mono`、U+FFFD+U+20E3 处为 `Roboto Mono`+`Microsoft Sans Serif` | 所有可用字形有墨迹;聚类/宽度保持未批准 | + +要点: + +- 全部 5 条样本经**真实 child** 接收到的 UTF-8 与源字节完全一致,回显字节也与文档化的 CR→CRLF 回显完全一致;因此“呈现源”与“接收 oracle”同源且可独立重算。 +- 核心对 ASCII/CJK/补充平面/结合标保持标量、`Cell.text` 与既有 width(含结合标 `marks=1`),与既有 15 项解析测试的表示一致;Qt 绘制按 lead cell 的 `width` 分配格宽。 +- 字体 fallback 不是猜测:`QTextLayout::glyphRuns()` 逐 cell 记录实际 `RawFont` family,`missing_glyphs=0`;CJK/emoji 确实用了宿主 fallback 字体。 +- `Segoe UI Emoji` 的 advance(21 px)大于两格 span(18 px),但同一字体无裁剪参考渲染的墨迹为 3..17 px,实际绘制墨迹也是 3..17 px:该样本没有可见裁切。该 `advance_over_span=true` 作为实测记录保留,不推广为“所有 emoji 都不会被裁”。 +- **ZWJ/keycap 实测缺口**:当前 Unicode 15 宽度表不含 U+200D/U+FE0F,recovery parser 按既有策略逐标量替换为 U+FFFD,因此产品呈现为 FFFD 而不是组合 emoji/keycap。该缺口只记录,不新增依赖、聚类或宽度策略;未来若支持,测试接受两种结果(精确或本文记录的 recovery 结果)。 +- 离屏抓图与字形测量都是**局部**证据:`presented_done=null`,不是物理上屏、不是原生 IME、不是真实 DPI 或 WindTerm 等价。 + +## 开发中出现并保留的失败(未改写为通过) + +1. 最初 fixture 用 `ReadFile(CONIN$)`(VT 输入)接收:BMP 文本正确,但补充平面标量被拆成 U+FFFD,receiver 与源字节不一致。改用**既有已验证的 `ReadConsoleW`+严格 UTF-8 转换**接收路径后一致;该失败说明“VT 输入 + ReadFile”不能作为非 BMP 接收 oracle,未用于通过。 +2. 离屏平台默认只装载应用内字体(`font_database_families=1`),CJK/emoji 无 fallback 面、无墨迹。测试改为在创建 `QApplication` 前把 `QT_QPA_FONTDIR` 指向宿主字体目录(`%WINDIR%\Fonts`),并用 `host_fallback_rendered` 字段显式区分“宿主有 fallback 面”与“环境限制”;本机实测为有 fallback。 +3. 曾把 emoji 的“advance ≤ span”当作失败断言;实测证明墨迹不越格而 advance 含右侧 bearing,改为“无裁剪参考墨迹必须落在 span 内 + advance 只记录”,不是放宽产品断言。 + +## 复验 + +```powershell +python -m tools.prepare_qt_build +cmake -S . -B build/qt-cpp -G "Visual Studio 17 2022" -A x64 ` + -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON ` + -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF ` + -DQt6_DIR="$PWD/local/qt-build-deps/qt/lib/cmake/Qt6" ` + -DTERMFORGE_CONPTY_BACKEND_DIR="$PWD/local/qt-build-deps/conpty" ` + -DTERMFORGE_MSVC_RUNTIME_DIR="$PWD/local/qt-build-deps/crt" +cmake --build build/qt-cpp --config Release --target qt_unicode_render_test --parallel 2 +ctest --test-dir build/qt-cpp -C Release -R '^qt_unicode_render$' --no-tests=error --output-on-failure +``` + +本次同源码结果:Qt 正式路径 CTest **6/6**(`qt_cpp_shell`、`qt_unicode_input`、`qt_unicode_render`、`qt_cpp_resize`、`qt_terminal_modes`、`qt_terminal_modes_retained`,合计 9.92 s);新测试连跑两次均通过,receiver/echo SHA-256 相同,除 child pid 与一个混合 cell 的 fallback 面列表顺序外逐字段一致。默认/核心配置 CTest **41/41**(27.61 s,含 `core` 的 16 项核心与 15 项 Unicode Python 测试、`keyboard`、`recovery`、`resize_replay_contract` 及全部 `*_core`)。完整源码/二进制/运行文件身份见 [validation.json](validation.json),不发布 SDK、部署目录或原始日志。 + +## 边界 + +- 只验证固定 5 样本、单显示器、离屏 offscreen、80×24、当前宿主字体;不验证物理呈现、真实 96/144 DPI、原生 IME、完整字素/整形/全部 emoji、选择/复制/Find、TUI、resize 交叉、多种字体配置或性能。字体 fallback 依赖宿主是否安装对应面,测试会记录 `host_fallback_rendered=false` 而不是把它当作产品通过。 +- 不重复既有 15 项核心解析测试、`qt_unicode_input` 的输入字节合同、旧 `f72c5c4` 联合输入回归或历史 Win32/DWrite 渲染证据;只补正式 Qt 路径未覆盖的“解析后实际绘制 + 独立 child 接收同源”合同。 +- 不关闭 #30/#155/#7,不宣称 R2 完成、WindTerm 等价或性能改善。 diff --git a/experiments/shell/results/2026-11-04-qt-unicode-presentation/validation.json b/experiments/shell/results/2026-11-04-qt-unicode-presentation/validation.json new file mode 100644 index 00000000..a160b91c --- /dev/null +++ b/experiments/shell/results/2026-11-04-qt-unicode-presentation/validation.json @@ -0,0 +1,957 @@ +{ + "schema": "termforge-qt-unicode-presentation-validation-v1", + "source_commit": "fbe4c4caf3ac050a4dc0c4ea61c28b0e0febc1d0", + "source_tree": "5b06235afe861cb0075ed0beda0ab7659fcd186d", + "source_binding": "Implementation, test, CI wiring and report were committed before the final builds and regressions; this validation file is a documentation follow-up. The working tree was clean at the start of verification, and the recorded implementation/test source hashes are the verified ones.", + "scope": "Fixed five-sample Unicode set through the formal Qt key path into a real child: independent ReadConsoleW receive bytes, CR->CRLF echo source, core Cell/width/marks, offscreen widget ink and per-cell RawFont fallback/missing-glyph/advance measurement. No new grapheme, clustering or width policy.", + "passed": true, + "scoped_validation_passed": true, + "complete_unicode_ime_verified": false, + "physical_presentation_verified": false, + "native_ime_verified": false, + "grapheme_width_policy_approved": false, + "r2_complete": false, + "windterm_parity": false, + "presented_done": null, + "environment": { + "os": "Windows 11 x64 build 26200", + "compiler": "MSVC 19.42.34433.0", + "qt": "6.10.3 win64_msvc2022_64, offscreen platform", + "conpty": "1.25.260930003", + "conpty_files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586" + }, + "msvc_runtime": "14.51.36247.0", + "python": "3.12.10", + "host_font_directory": "%WINDIR%/Fonts (used only to let the offscreen font database load the real fallback faces; the product font is loaded by path)", + "font_database_families": 94 + }, + "dependencies": { + "qt_archive_sha256": "4db84dee7fe3c558f242bef0a88852613af76580dc6d2b24596479f47004dad7", + "sdk_tree_sha256": "a35ab6fe76b62e4d9bd6d3c6b0d476c63583070cea5c9118ef5bb02042ffe266", + "conpty_package_sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "msvc_runtime_package_sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c", + "input_manifests": { + "qt": "2796294f254f7fe0370be99d2de8f7745734c7352ac2648b1f8d9a8fab5ad2cd", + "conpty": "e75e522a3355c0a66c6d60cafa02413a83d8757262747f650b77c959c09ed439", + "msvc_runtime": "154176c8b4ae6dd57fce453e2c2002ff484344b39451fa9880b07db4794f3758" + } + }, + "commands": { + "configure": "cmake -S . -B build/qt-cpp -G \"Visual Studio 17 2022\" -A x64 -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF -DQt6_DIR=/qt/lib/cmake/Qt6 -DTERMFORGE_CONPTY_BACKEND_DIR=/conpty -DTERMFORGE_MSVC_RUNTIME_DIR=/crt", + "new_target": "cmake --build build/qt-cpp --config Release --target qt_unicode_render_test --parallel 2", + "new_test": "ctest --test-dir build/qt-cpp -C Release -R '^qt_unicode_render$' --no-tests=error --output-on-failure", + "qt_regression": "ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?)$' --output-on-failure", + "core_regression": "build the default configuration, then ctest --test-dir build -C Release -R '^(.*_core|core|resize_replay_contract|recovery|keyboard)$' --output-on-failure" + }, + "results": { + "qt_formal_path_ctest": { + "tests": [ + "qt_cpp_shell", + "qt_unicode_input", + "qt_unicode_render", + "qt_cpp_resize", + "qt_terminal_modes", + "qt_terminal_modes_retained" + ], + "passed": 6, + "failed": 0, + "seconds": 9.92 + }, + "core_ctest": { + "includes": "notably core (16 core + 15 Unicode Python tests), keyboard, recovery, resize_replay_contract and every *_core native check", + "passed": 41, + "failed": 0, + "seconds": 27.61 + }, + "new_test_runs": { + "supervisor": "termforge-qt-cpp-supervisor-v1", + "runs": 2, + "both_passed": true, + "job_after": { + "active": 0, + "total": 6, + "terminated": 0 + }, + "content_identical_ignoring_child_pid": true, + "nondeterministic_fields": [ + "child_pid", + "QTextLayout glyph-run family order for one mixed U+FFFD+U+20E3 cell" + ], + "receiver_bytes": 61, + "receiver_sha256": "32a5695a4b750861291df737ed9c8d4e7a92726172d6f275ba6c88790fd13f1e", + "echo_bytes": 96, + "echo_sha256": "96ae6f16c2e6449673b10871fc10881c11566a2de150a5dcb759ebca55628028", + "child_pid_run1": 14236, + "child_pid_run2": 5876, + "isolation_child_pid": 29048, + "isolation_received_hex": "", + "isolation_sample_text_absent": true + }, + "python_unicode_tests": { + "tests": 15, + "passed": true + }, + "python_core_tests": { + "tests": 16, + "passed": true + } + }, + "product_identity": { + "backend": "cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 80x24", + "font_family": "Roboto Mono", + "bundled_font_sha256": "af0bff7599c3df3831755c16e39b3c496df74b8c8d8a1161b14dc8461be17cb4", + "grid": [ + 80, + 24 + ], + "cell_geometry": [ + 9, + 22 + ], + "host_fallback_verified": true + }, + "samples": [ + { + "id": "ascii", + "asserted": true, + "source_hex": "5465726d466f726765204153434949", + "parsed_hex": "5465726d466f726765204153434949", + "recovery_replaced_scalars": false, + "host_fallback_required": false, + "host_fallback_rendered": true, + "fallback_cells": 0, + "display_span_px": 135, + "glyph_families": [ + "Roboto Mono" + ], + "cells": [ + { + "marks": 0, + "text_hex": "54", + "width": 1 + }, + { + "marks": 0, + "text_hex": "65", + "width": 1 + }, + { + "marks": 0, + "text_hex": "72", + "width": 1 + }, + { + "marks": 0, + "text_hex": "6d", + "width": 1 + }, + { + "marks": 0, + "text_hex": "46", + "width": 1 + }, + { + "marks": 0, + "text_hex": "6f", + "width": 1 + }, + { + "marks": 0, + "text_hex": "72", + "width": 1 + }, + { + "marks": 0, + "text_hex": "67", + "width": 1 + }, + { + "marks": 0, + "text_hex": "65", + "width": 1 + }, + { + "marks": 0, + "text_hex": "20", + "width": 1 + }, + { + "marks": 0, + "text_hex": "41", + "width": 1 + }, + { + "marks": 0, + "text_hex": "53", + "width": 1 + }, + { + "marks": 0, + "text_hex": "43", + "width": 1 + }, + { + "marks": 0, + "text_hex": "49", + "width": 1 + }, + { + "marks": 0, + "text_hex": "49", + "width": 1 + } + ], + "painted_cells": [ + { + "column": 0, + "text_hex": "54", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 0, + 8 + ], + "reference_ink_span": [ + 0, + 8 + ] + }, + { + "column": 1, + "text_hex": "65", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 7 + ], + "reference_ink_span": [ + 1, + 7 + ] + }, + { + "column": 2, + "text_hex": "72", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 2, + 7 + ], + "reference_ink_span": [ + 2, + 7 + ] + }, + { + "column": 3, + "text_hex": "6d", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 0, + 8 + ], + "reference_ink_span": [ + 0, + 8 + ] + }, + { + "column": 4, + "text_hex": "46", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 7 + ], + "reference_ink_span": [ + 1, + 7 + ] + }, + { + "column": 5, + "text_hex": "6f", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 0, + 7 + ], + "reference_ink_span": [ + 0, + 7 + ] + }, + { + "column": 6, + "text_hex": "72", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 2, + 7 + ], + "reference_ink_span": [ + 2, + 7 + ] + }, + { + "column": 7, + "text_hex": "67", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 7 + ], + "reference_ink_span": [ + 1, + 7 + ] + }, + { + "column": 8, + "text_hex": "65", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 7 + ], + "reference_ink_span": [ + 1, + 7 + ] + }, + { + "column": 10, + "text_hex": "41", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 0, + 8 + ], + "reference_ink_span": [ + 0, + 8 + ] + }, + { + "column": 11, + "text_hex": "53", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 8 + ], + "reference_ink_span": [ + 1, + 8 + ] + }, + { + "column": 12, + "text_hex": "43", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 0, + 8 + ], + "reference_ink_span": [ + 0, + 8 + ] + }, + { + "column": 13, + "text_hex": "49", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 7 + ], + "reference_ink_span": [ + 1, + 7 + ] + }, + { + "column": 14, + "text_hex": "49", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 7 + ], + "reference_ink_span": [ + 1, + 7 + ] + } + ] + }, + { + "id": "cjk", + "asserted": true, + "source_hex": "e6b189e5ad97e7bb88e7abaf", + "parsed_hex": "e6b189e5ad97e7bb88e7abaf", + "recovery_replaced_scalars": false, + "host_fallback_required": true, + "host_fallback_rendered": true, + "fallback_cells": 4, + "display_span_px": 72, + "glyph_families": [ + "Alibaba PuHuiTi" + ], + "cells": [ + { + "marks": 0, + "text_hex": "e6b189", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + }, + { + "marks": 0, + "text_hex": "e5ad97", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + }, + { + "marks": 0, + "text_hex": "e7bb88", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + }, + { + "marks": 0, + "text_hex": "e7abaf", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + } + ], + "painted_cells": [ + { + "column": 0, + "text_hex": "e6b189", + "width": 2, + "marks": 0, + "families": [ + "Alibaba PuHuiTi" + ], + "missing_glyphs": 0, + "advance": 15, + "advance_over_span": false, + "cell_span_px": 18, + "ink_span": [ + 0, + 14 + ], + "reference_ink_span": [ + 0, + 14 + ] + }, + { + "column": 2, + "text_hex": "e5ad97", + "width": 2, + "marks": 0, + "families": [ + "Alibaba PuHuiTi" + ], + "missing_glyphs": 0, + "advance": 15, + "advance_over_span": false, + "cell_span_px": 18, + "ink_span": [ + 0, + 14 + ], + "reference_ink_span": [ + 0, + 14 + ] + }, + { + "column": 4, + "text_hex": "e7bb88", + "width": 2, + "marks": 0, + "families": [ + "Alibaba PuHuiTi" + ], + "missing_glyphs": 0, + "advance": 15, + "advance_over_span": false, + "cell_span_px": 18, + "ink_span": [ + 0, + 14 + ], + "reference_ink_span": [ + 0, + 14 + ] + }, + { + "column": 6, + "text_hex": "e7abaf", + "width": 2, + "marks": 0, + "families": [ + "Alibaba PuHuiTi" + ], + "missing_glyphs": 0, + "advance": 15, + "advance_over_span": false, + "cell_span_px": 18, + "ink_span": [ + 0, + 13 + ], + "reference_ink_span": [ + 0, + 13 + ] + } + ] + }, + { + "id": "supplementary", + "asserted": true, + "source_hex": "f09f9982f09f9880", + "parsed_hex": "f09f9982f09f9880", + "recovery_replaced_scalars": false, + "host_fallback_required": true, + "host_fallback_rendered": true, + "fallback_cells": 2, + "display_span_px": 36, + "glyph_families": [ + "Segoe UI Emoji" + ], + "cells": [ + { + "marks": 0, + "text_hex": "f09f9982", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + }, + { + "marks": 0, + "text_hex": "f09f9880", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + } + ], + "painted_cells": [ + { + "column": 0, + "text_hex": "f09f9982", + "width": 2, + "marks": 0, + "families": [ + "Segoe UI Emoji" + ], + "missing_glyphs": 0, + "advance": 21, + "advance_over_span": true, + "cell_span_px": 18, + "ink_span": [ + 3, + 17 + ], + "reference_ink_span": [ + 3, + 17 + ] + }, + { + "column": 2, + "text_hex": "f09f9880", + "width": 2, + "marks": 0, + "families": [ + "Segoe UI Emoji" + ], + "missing_glyphs": 0, + "advance": 21, + "advance_over_span": true, + "cell_span_px": 18, + "ink_span": [ + 3, + 17 + ], + "reference_ink_span": [ + 3, + 17 + ] + } + ] + }, + { + "id": "combining", + "asserted": true, + "source_hex": "65cc81", + "parsed_hex": "65cc81", + "recovery_replaced_scalars": false, + "host_fallback_required": false, + "host_fallback_rendered": true, + "fallback_cells": 0, + "display_span_px": 9, + "glyph_families": [ + "Roboto Mono" + ], + "cells": [ + { + "marks": 1, + "text_hex": "65cc81", + "width": 1 + } + ], + "painted_cells": [ + { + "column": 0, + "text_hex": "65cc81", + "width": 1, + "marks": 1, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 7 + ], + "reference_ink_span": [ + 1, + 7 + ] + } + ] + }, + { + "id": "zwj-keycap", + "asserted": false, + "source_hex": "f09f91a9e2808df09f92bb31efb88fe283a3", + "parsed_hex": "f09f91a9efbfbdf09f92bb31efbfbde283a3", + "recovery_replaced_scalars": true, + "host_fallback_required": true, + "host_fallback_rendered": true, + "fallback_cells": 2, + "display_span_px": 63, + "glyph_families": [ + "Microsoft Sans Serif", + "Roboto Mono", + "Segoe UI Emoji" + ], + "cells": [ + { + "marks": 0, + "text_hex": "f09f91a9", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + }, + { + "marks": 0, + "text_hex": "efbfbd", + "width": 1 + }, + { + "marks": 0, + "text_hex": "f09f92bb", + "width": 2 + }, + { + "marks": 0, + "text_hex": "", + "width": 0 + }, + { + "marks": 0, + "text_hex": "31", + "width": 1 + }, + { + "marks": 1, + "text_hex": "efbfbde283a3", + "width": 1 + } + ], + "painted_cells": [ + { + "column": 0, + "text_hex": "f09f91a9", + "width": 2, + "marks": 0, + "families": [ + "Segoe UI Emoji" + ], + "missing_glyphs": 0, + "advance": 21, + "advance_over_span": true, + "cell_span_px": 18, + "ink_span": [ + 3, + 17 + ] + }, + { + "column": 2, + "text_hex": "efbfbd", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 0, + 8 + ] + }, + { + "column": 3, + "text_hex": "f09f92bb", + "width": 2, + "marks": 0, + "families": [ + "Segoe UI Emoji" + ], + "missing_glyphs": 0, + "advance": 21, + "advance_over_span": true, + "cell_span_px": 18, + "ink_span": [ + 3, + 17 + ] + }, + { + "column": 5, + "text_hex": "31", + "width": 1, + "marks": 0, + "families": [ + "Roboto Mono" + ], + "missing_glyphs": 0, + "advance": 9, + "advance_over_span": false, + "cell_span_px": 9, + "ink_span": [ + 1, + 5 + ] + }, + { + "column": 6, + "text_hex": "efbfbde283a3", + "width": 1, + "marks": 1, + "families": [ + "Roboto Mono", + "Microsoft Sans Serif" + ], + "missing_glyphs": 0, + "advance": 18, + "advance_over_span": true, + "cell_span_px": 9, + "ink_span": [ + 0, + 8 + ] + } + ] + } + ], + "observed_gaps": [ + "U+200D (ZWJ) and U+FE0F (VS16) are not covered by the core Unicode 15 width table, so the recovery parser replaces each with U+FFFD; the record-only ZWJ/keycap sample is parsed as U+1F469 U+FFFD U+1F4BB '1' U+FFFD U+20E3. Recorded only; no clustering or width policy is approved.", + "Segoe UI Emoji advances 21 px in an 18 px (two-cell) span; the unclipped reference ink is 3..17 px and the painted ink is also 3..17 px, so these two scalars are not visibly clipped. This is not generalised to other emoji.", + "The offscreen platform only loads the host font faces after QT_QPA_FONTDIR points at %WINDIR%/Fonts; without them the test records host_fallback_rendered=false instead of passing a glyph check." + ], + "source_hashes": { + "CMakeLists.txt": "b390893ae8547b380eeaad81529cce9cd3425810c910e19e7556f24b63a1ab37", + ".github/workflows/qt-cpp.yml": "65ba8756f0891d2f0b3897d2b7d92b28174c97d74a04bee48e80a363e22ddbea", + "src/qt/terminal_widget.hpp": "f1f7724711ae8937e443a61886f9b14fb6e919ddd7f8473dea1044de8dc30e0c", + "src/qt/unicode_render_test.cpp": "41a1c3a09c4425f69528fdfc4b0ccef085deb996cbd9a3715b9cc3b04060bcd5", + "experiments/shell/unicode_echo_client.cpp": "443570c158e6be7cdf0e94072e1a2082bdc0f35e7653bc1eae7373d175c0f3a7", + "src/qt/README.md": "9540fac8a12405cf13946a9079f0b9d2dcb57cb9ae5ad5f3ef72d8b2953e8e4d", + "docs/EVIDENCE.md": "ec4f44e13db3844d4e47ca6a6edab57ebb558b6fac05e577ce056b5a8de82300", + "docs/FEATURES.md": "ef4f1bf39aa47ccbd5468cfcc4042ea605df5959c5671f070aecaf5aa37cf39c", + "experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md": "20753218c990a0fc8f1fbe2ffd1b708fc562178f33ce44a826b7cf38a88cc063" + }, + "artifact_hashes": { + "build/qt-cpp/qt-unicode-render-bin/Release/qt_unicode_render_test.exe": { + "bytes": 277504, + "sha256": "ea147202844ac43be919d14e557606ec329c6e390319b1e7e50d4a29a00bea95" + }, + "build/qt-cpp/bin/Release/shell_unicode_echo_client.exe": { + "bytes": 34816, + "sha256": "374dbec8fe84b604c190072e928b7805c88dd9e71fac9d96173c3dd32e707ff4" + }, + "build/qt-cpp/qt-bin/Release/termforge_qt_shell.exe": { + "bytes": 223232, + "sha256": "24488bb9ccc7559902c174180e1780900868a9d79b600c2c5e8c722e54b5b1b2" + }, + "build/qt-cpp/qt-unicode-render-bin/Release/assets/fonts/RobotoMono-Regular.ttf": { + "bytes": 125748, + "sha256": "af0bff7599c3df3831755c16e39b3c496df74b8c8d8a1161b14dc8461be17cb4" + }, + "build/qt-cpp/qt-unicode-render-bin/Release/conpty-backend/ConPTY.dll": { + "bytes": 119608, + "sha256": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a" + } + }, + "implementation_sources_match_recorded_commit": true, + "limitations": [ + "Offscreen local capture only: not physical presentation, not a presented-frame endpoint (presented_done=null), not real 96/144 DPI, not native IME, not a physical keyboard.", + "Fallback faces depend on the host font set; only the faces actually selected on this host are recorded. The test does not accept a general fallback contract.", + "Fixed five samples and 80x24 grid only; no resize/alternate-screen cross, no selection, copy, Find, TUI, multi-font or performance check.", + "Existing 15 Unicode parser tests, qt_unicode_input byte contracts, the old f72c5c4 joint input regression and the historical Win32/DWrite renderer evidence are not repeated or replaced.", + "The Windows-platform (qwindows) presented frame and non-BMP input via VT ReadFile are not verified; the fixture uses the already verified ReadConsoleW receive path." + ] +} diff --git a/experiments/shell/unicode_echo_client.cpp b/experiments/shell/unicode_echo_client.cpp new file mode 100644 index 00000000..12c3cedf --- /dev/null +++ b/experiments/shell/unicode_echo_client.cpp @@ -0,0 +1,123 @@ +// Independent console receiver that echoes exactly the UTF-8 it received. +// +// The fixture reads the console input as UTF-16 units (ENABLE_VIRTUAL_TERMINAL_INPUT), +// keeps them until the EOT unit the test sends at the end, converts them to strict +// UTF-8, writes those bytes verbatim to a file (the receive oracle) and then echoes +// the same bytes to the console output so the formal Qt/ConPTY presentation path +// parses and paints them. The only transformation is the console line discipline: +// each received CR becomes CRLF on output, because the echo disabled by VT input +// mode is what would normally terminate a displayed line. Both the received and the +// echoed byte sequences are written to separate files so a test can compare the +// display source independently. +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr size_t maximum_units = 4096; + +class Handle { + HANDLE value_ = nullptr; +public: + explicit Handle(HANDLE value) : value_(value) { + if (!value_ || value_ == INVALID_HANDLE_VALUE) + throw std::runtime_error("create/open handle failed: " + std::to_string(GetLastError())); + } + ~Handle() { CloseHandle(value_); } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + HANDLE get() const { return value_; } +}; + +std::string utf8(const std::wstring& units) { + if (units.empty()) return {}; + const int bytes = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), + static_cast(units.size()), nullptr, 0, nullptr, nullptr); + if (bytes <= 0) throw std::runtime_error("console input is not valid UTF-16"); + std::string encoded(static_cast(bytes), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), static_cast(units.size()), + encoded.data(), bytes, nullptr, nullptr) != bytes) + throw std::runtime_error("console input UTF-8 conversion failed"); + return encoded; +} + +std::string echo_payload(const std::string& received) { + std::string payload = "TF_ECHO_BEGIN\r\n"; + for (const char byte : received) { + if (byte == '\r') payload += "\r\n"; + else payload += byte; + } + payload += "\r\nTF_ECHO_END\r\n"; + return payload; +} + +void write_file(const std::wstring& path, const std::string& bytes) { + std::ofstream file(std::filesystem::path(path), std::ios::binary | std::ios::trunc); + if (!file) throw std::runtime_error("receiver output could not be created"); + file.write(bytes.data(), static_cast(bytes.size())); + file.flush(); + if (!file) throw std::runtime_error("receiver output could not be written"); +} + +bool write_console(HANDLE output, const std::string& bytes) { + DWORD written = 0; + return WriteFile(output, bytes.data(), static_cast(bytes.size()), &written, nullptr) && + written == bytes.size(); +} + +int run(const std::wstring& receiver_path, const std::wstring& echo_path) { + BOOL owned = FALSE; + if (!IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) + throw std::runtime_error("echo receiver requires its owned Session Job"); + Handle input(CreateFileW(L"CONIN$", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, OPEN_EXISTING, 0, nullptr)); + Handle output(CreateFileW(L"CONOUT$", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, + OPEN_EXISTING, 0, nullptr)); + if (!SetConsoleCP(CP_UTF8) || !SetConsoleOutputCP(CP_UTF8) || + !SetConsoleMode(input.get(), ENABLE_VIRTUAL_TERMINAL_INPUT) || + !SetConsoleMode(output.get(), ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | + ENABLE_VIRTUAL_TERMINAL_PROCESSING | + DISABLE_NEWLINE_AUTO_RETURN)) + throw std::runtime_error("console mode/encoding setup failed"); + if (!write_console(output.get(), "TF_ECHO_READY\r\n")) throw std::runtime_error("ready marker write failed"); + + std::wstring received; + bool complete = false; + while (!complete) { + wchar_t units[256]; + DWORD count = 0; + if (!ReadConsoleW(input.get(), units, static_cast(std::size(units)), &count, nullptr) || count == 0) + throw std::runtime_error("console input read failed"); + for (DWORD index = 0; index < count; ++index) { + if (units[index] == 4) { + // EOT is this fixture's only end marker; input after it is invalid. + if (index + 1 != count) throw std::runtime_error("input after EOT"); + complete = true; + break; + } + received += units[index]; + if (received.size() > maximum_units) throw std::runtime_error("received text exceeds its fixed bound"); + } + } + const std::string received_utf8 = utf8(received); + write_file(receiver_path, received_utf8); + const std::string echoed = echo_payload(received_utf8); + write_file(echo_path, echoed); + if (!write_console(output.get(), echoed)) throw std::runtime_error("echo output write failed"); + return 0; +} +} // namespace + +int wmain(int argc, wchar_t** argv) { + if (argc != 3) return 2; + try { + return run(argv[1], argv[2]); + } catch (const std::exception& error) { + std::fprintf(stderr, "TermForge Unicode echo receiver: %s\n", error.what()); + return 11; + } +} diff --git a/src/qt/README.md b/src/qt/README.md index e69a362f..b2aa5dcd 100644 --- a/src/qt/README.md +++ b/src/qt/README.md @@ -25,12 +25,12 @@ cmake -S . -B build/qt-cpp -G "Visual Studio 17 2022" -A x64 ` -DQt6_DIR="/lib/cmake/Qt6" ` -DTERMFORGE_CONPTY_BACKEND_DIR="" ` -DTERMFORGE_MSVC_RUNTIME_DIR="" -cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_terminal_modes_test +cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test build/qt-cpp/qt-bin/Release/termforge_qt_shell.exe -ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_terminal_modes(_retained)?)$' --output-on-failure +ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?)$' --output-on-failure ``` -当前主线注册的 Qt CTest 包括上述四项与 `qt_terminal_modes_retained`(只缩宽的保留/投影契约);IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。旧六阶段 `--resize` 仍以显式诊断运行并保留失败,不注册进默认 CI。 +当前主线注册的 Qt CTest 包括上述五项与 `qt_terminal_modes_retained`(只缩宽的保留/投影契约)、`qt_unicode_render`(固定 Unicode 样本的独立接收 + 离屏绘制/fallback 记录);IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。旧六阶段 `--resize` 仍以显式诊断运行并保留失败,不注册进默认 CI。 固定 ConPTY/CRT 输入缺失或篡改时构建明确失败;实际工具集更新到固定 CRT 之外时须重新审查依赖。产品中的 shell 启动失败保留可关闭的错误窗口。随目录保留 Qt 动态运行库、平台插件、`conpty-backend/`、`assets/fonts/`、十个固定 CRT DLL 及其原许可/NOTICE;每次构建重新校验并部署 CRT,不运行安装器、不修改 System32。最终目录用 `tools/msvc_runtime.py manifest` 记录全部文件。当前只是本地构建输出,Qt/第三方的完整分发审计及产品打包尚未完成。 @@ -50,6 +50,8 @@ Parser/Screen 仅在 Qt 线程修改;ConPTY reader 向有界输出队列写入 `qt_unicode_input` 通过公开 Qt 文本/焦点/隐藏/标签动作,将 UTF-16 整事件及跨事件代理项经每目标 `KeyboardEncoder` 交给固定 ConPTY,独立 `ReadConsoleW` 接收端核对中文、NFD、非BMP、固定ZWJ/keycap、NUL与原有正常模式按键的精确UTF-8。整包1024B上限与拒绝反馈、失焦/关闭/不可用目标的状态隔离有[红绿与联合源码验证](../../experiments/shell/results/2026-10-29-qt-unicode-input/README.md);未配对单元及焦点清理是当前 TermForge 策略,尚无原版对照。该测试核对按键文本编码,不替代 OS IME 事件、相应字形、字素宽度或原始物理按键验收。 +`qt_unicode_render` 补上同一正式路径此前未覆盖的“解析后实际绘制”合同:固定 ASCII/CJK/补充平面/结合标样本经公开 Qt 事件进入真实 child,独立 `ReadConsoleW` 接收端与 child 回显文件分别核对接收字节和呈现源;核心解析为既有 `Cell`/width/marks 后由 `TerminalWidget` 离屏抓图,并逐 cell 记录实际 glyph faces、missing glyph、advance 与无裁剪参考墨迹,同时用第二条空会话观察目标隔离。实测 CJK 使用宿主 fallback 字体、emoji 的 advance 超出两格但参考墨迹未越格;U+200D/U+FE0F 被 recovery parser 替换为 U+FFFD(既有缺口,只记录)。该检查只证明离屏局部链,`presented_done=null`,不是物理呈现、原生 IME 或字素/宽度策略的批准,详见[固定结果](../../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md)。 + IME 实现通过 `QInputMethodEvent` 绘制未提交的 preedit,仅把 commit 的 UTF-8 交给会话;空 preedit 取消预览,失焦/隐藏清理预览,光标格通过 `ImCursorRectangle` 查询。预览按字体度量计算宽度,失败提交保留错误反馈。`qt_cpp_shell` 的合成事件检查覆盖 preedit 不进入终端正文、commit 到达、cancel 不写入。独立的[固定微软拼音记录](../../research/ui/results/2026-10-30-qt-ime-fixed-pinyin/README.md)已有隔离 Windows VM 中 `ni'hao` 组合、取消及「你好」提交的限定证据;完整会话交叉、候选窗身份、其他 DPI 和实际呈现仍由 [#155](https://github.com/JavaZeroo/termforge/issues/155) / [#30](https://github.com/JavaZeroo/termforge/issues/30) 接续。 [N11 候选框几何对照](../../docs/research/qt-ime-candidate-panel-reference-2026-11-01.md)在冻结源 `5fab30b` 与官方 WindTerm 2.7.0 上各采集三次:输出后两侧面板均保持旧位置,resize 后均在固定延后采集点前更新;原版字体放大未触发,字体阶段仍不可比。这份历史冻结包证据不等于当前主线已复验;亮度带几何不证明候选窗身份,原版三次收尾关闭均失败,也不能把几何片段记成整次原生验收通过。 diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 46fe6270..6ddb1727 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -442,6 +442,10 @@ class TerminalWidget final : public QWidget { // never mutate the painted page or the protocol cursor. const std::optional& painted_page() const { return painted_page_; } size_t painted_row_offset() const { return painted_row_offset_; } + // Diagnostic geometry of the owned paint chain; tests only read it. + int cell_width() const { return cell_width_; } + int cell_height() const { return cell_height_; } + static int content_margin() { return margin; } int display_caret_row() const { return display_cursor_row_; } int display_caret_column() const { return display_cursor_column_; } QSize sizeHint() const override { diff --git a/src/qt/unicode_render_test.cpp b/src/qt/unicode_render_test.cpp new file mode 100644 index 00000000..060cd303 --- /dev/null +++ b/src/qt/unicode_render_test.cpp @@ -0,0 +1,556 @@ +// Fixed Unicode samples through the formal Qt / fixed-ConPTY path. +// +// One real child process receives the sample bytes from the Qt key-event path, +// writes them to a receive file (the independent byte oracle) and echoes those +// same bytes to the console output. The terminal parses that output and the widget +// paints it; an offscreen capture then measures actual ink and glyph runs for the +// same font. This is a local offscreen check only: it is not physical presentation, +// not an OS IME, and it does not approve a new grapheme/width policy. +#include "terminal_widget.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; + +namespace { +std::string_view echo_begin() { return "TF_ECHO_BEGIN"; } +std::string_view echo_end() { return "TF_ECHO_END"; } + +struct ExpectedCell { + std::string text; + int width; + size_t marks; +}; +struct Sample { + const char* id; + std::string utf8; + bool asserted; // false: record observed cells/glyphs but approve no width policy + bool host_fallback; // true: requires a host (non-bundled) font for this script + std::vector cells; +}; + +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +template +void await(Predicate predicate, const char* message) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < 5000) { + QApplication::processEvents(QEventLoop::AllEvents, 20); + QTest::qWait(5); + } + require(predicate(), message); +} +std::string screen_line(const termforge::Screen& screen, size_t row) { + std::string line; + for (const auto& cell : screen.visible_lines().at(row)) + if (cell.width) line += cell.text; + while (!line.empty() && line.back() == ' ') line.pop_back(); + return line; +} +bool has_text(const termforge::Screen& screen, std::string_view text) { + for (size_t row = 0; row < screen.visible_lines().size(); ++row) + if (screen_line(screen, row).find(text) != std::string::npos) return true; + return false; +} +size_t find_line(const termforge::Screen& screen, std::string_view text) { + for (size_t row = 0; row < screen.visible_lines().size(); ++row) + if (screen_line(screen, row).find(text) != std::string::npos) return row; + return screen.visible_lines().size(); +} +bool text_event(QWidget& target, const QString& text) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_unknown, Qt::NoModifier, text); + event.setAccepted(false); + QApplication::sendEvent(&target, &event); + return event.isAccepted(); +} +void focus(TerminalWidget& terminal) { + terminal.show(); + QApplication::setActiveWindow(terminal.window()); + terminal.setFocus(); + QApplication::processEvents(); + require(terminal.hasFocus(), "key event target has no focus"); +} +std::string read_file(const QString& path) { + QFile file(path); + require(file.open(QIODevice::ReadOnly), "fixture output is missing"); + const QByteArray bytes = file.readAll(); + return {bytes.constData(), static_cast(bytes.size())}; +} +std::string sha256_hex(std::string_view bytes) { + return QCryptographicHash::hash(QByteArray(bytes.data(), static_cast(bytes.size())), + QCryptographicHash::Sha256).toHex().toStdString(); +} +std::string hex(std::string_view bytes) { + static constexpr char digits[] = "0123456789abcdef"; + std::string encoded; + encoded.reserve(bytes.size() * 2); + for (const char byte : bytes) { + encoded += digits[static_cast(byte) >> 4]; + encoded += digits[static_cast(byte) & 0x0f]; + } + return encoded; +} +void finish(SessionModel& model) { + // EOT belongs only to this fixture protocol; it is not a product Ctrl-D action. + require(model.send(std::string(1, '\x04')), "fixture EOT was rejected"); + await([&] { return model.finished(); }, "fixture did not finish"); + require(model.natural_exit() && model.exit_code() == 0 && model.output_eof() && + model.cleanup_verified() && model.active_processes_after() == 0, + "fixture did not exit naturally with verified Job cleanup"); +} +std::string expected_echo(const std::string& received) { + // Independent construction of the fixture's documented echo: CR becomes CRLF. + std::string payload = std::string(echo_begin()) + "\r\n"; + for (const char byte : received) { + if (byte == '\r') payload += "\r\n"; + else payload += byte; + } + payload += std::string("\r\n") + std::string(echo_end()) + "\r\n"; + return payload; +} +QString cell_string(const termforge::Cell& cell) { + return QString::fromUtf8(cell.text.data(), static_cast(cell.text.size())); +} +struct GlyphObservation { + std::vector families; + int glyphs = 0; + int missing = 0; + qreal advance = 0; +}; +// Parallel measurement of the same font/text pair the widget paints per cell. +// It records the fallback faces actually chosen and any notdef glyph, but it is not +// a hook inside QPainter and therefore claims no per-pixel glyph attribution. +GlyphObservation observe_glyphs(const QString& text, const QFont& font) { + GlyphObservation observation; + if (text.isEmpty()) return observation; + QTextLayout layout(text, font); + layout.beginLayout(); + QTextLine line = layout.createLine(); + line.setLineWidth(4096.0); + layout.endLayout(); + observation.advance = line.naturalTextWidth(); + for (const QGlyphRun& run : layout.glyphRuns()) { + const QString family = run.rawFont().familyName(); + if (std::find(observation.families.begin(), observation.families.end(), family) == observation.families.end()) + observation.families.push_back(family); + const auto indexes = run.glyphIndexes(); + observation.glyphs += static_cast(indexes.size()); + for (const quint32 index : indexes) + if (index == 0) ++observation.missing; + } + return observation; +} +struct InkBox { + int count = 0; + int left = 0, top = 0, right = -1, bottom = -1; +}; +InkBox measure_ink(const QImage& image, int x_begin, int x_end, int y_begin, int y_end, const QColor& background) { + InkBox box; + for (int y = (std::max)(0, y_begin); y < (std::min)(image.height(), y_end); ++y) { + for (int x = (std::max)(0, x_begin); x < (std::min)(image.width(), x_end); ++x) { + const QColor pixel = image.pixelColor(x, y); + const int difference = std::abs(pixel.red() - background.red()) + + std::abs(pixel.green() - background.green()) + + std::abs(pixel.blue() - background.blue()); + if (difference <= 24) continue; + if (!box.count) { + box.left = box.right = x; + box.top = box.bottom = y; + } else { + box.left = (std::min)(box.left, x); + box.right = (std::max)(box.right, x); + box.top = (std::min)(box.top, y); + box.bottom = (std::max)(box.bottom, y); + } + ++box.count; + } + } + return box; +} +// Unclipped reference rendering of the same cell text with the same font. The +// widget clips each cell to its grid span, so comparing the reference ink extent +// with that span shows whether a fallback glyph would visibly spill. The pen colour +// is the widget's documented default foreground (palette index 7); the test fails +// if the actual paint chain stops matching it. +InkBox reference_ink(const QString& text, const QFont& font, const QColor& background, + const QColor& foreground, int width, int height) { + QImage reference(width, height, QImage::Format_RGB32); + reference.fill(background); + QPainter painter(&reference); + painter.setFont(font); + painter.setPen(foreground); + painter.drawText(QRectF(0, 0, width, height), Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, text); + painter.end(); + return measure_ink(reference, 0, width, 0, height, background); +} +std::vector expect_ascii(const std::string& text) { + std::vector cells; + for (const char byte : text) cells.push_back({std::string(1, byte), 1, 0}); + return cells; +} +std::vector expect_wide(const std::vector& scalars) { + std::vector cells; + for (const auto& scalar : scalars) { + cells.push_back({scalar, 2, 0}); // lead cell of a width-2 scalar + cells.push_back({"", 0, 0}); // its trailing cell + } + return cells; +} +std::vector sample_set() { + return { + {"ascii", "TermForge ASCII", true, false, expect_ascii("TermForge ASCII")}, + {"cjk", "\xe6\xb1\x89\xe5\xad\x97\xe7\xbb\x88\xe7\xab\xaf", true, true, + expect_wide({"\xe6\xb1\x89", "\xe5\xad\x97", "\xe7\xbb\x88", "\xe7\xab\xaf"})}, + {"supplementary", "\xf0\x9f\x99\x82\xf0\x9f\x98\x80", true, true, + expect_wide({"\xf0\x9f\x99\x82", "\xf0\x9f\x98\x80"})}, + {"combining", "e\xcc\x81", true, false, {{"e\xcc\x81", 1, 1}}}, + // Record-only: scalar preservation is existing coverage, but ZWJ/keycap + // clustering and width are unresolved (#39). Observe, do not approve. + {"zwj-keycap", "\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x92\xbb" "1\xef\xb8\x8f\xe2\x83\xa3", false, true, {}}, + }; +} +void check_cells(const termforge::Screen& screen, size_t row, const Sample& sample) { + const auto& line = screen.visible_lines().at(row); + require(line.size() >= sample.cells.size(), std::string(sample.id) + ": row is shorter than its literal cells"); + for (size_t column = 0; column < sample.cells.size(); ++column) { + const auto& expected = sample.cells[column]; + const auto& cell = line[column]; + require(cell.text == expected.text, std::string(sample.id) + ": cell text changed"); + require(cell.width == expected.width, std::string(sample.id) + ": cell width changed"); + require(cell.marks == expected.marks, std::string(sample.id) + ": cell marks changed"); + } +} +QJsonArray observed_cells(const termforge::Screen& screen, size_t row) { + QJsonArray cells; + for (const auto& cell : screen.visible_lines().at(row)) { + // Skip untouched padding; explicit spaces, wide-scalar trails and marks are + // part of the observed row. + if (!cell.written && cell.marks == 0 && cell.text == " ") continue; + cells.append(QJsonObject{{"text_hex", QString::fromStdString(hex(cell.text))}, + {"width", cell.width}, + {"marks", static_cast(cell.marks)}}); + } + return cells; +} +QJsonObject presentation_case(const QString& receiver, const QString& directory) { + const QString receiver_path = directory + QStringLiteral("/unicode-received.bin"); + const QString echo_path = directory + QStringLiteral("/unicode-echo.bin"); + SessionModel model; + TerminalWidget terminal(model); + focus(terminal); + require(model.start(receiver.toStdWString(), + (QStringLiteral("\"") + receiver_path + QStringLiteral("\" \"") + echo_path + + QLatin1Char('"')).toStdWString(), + directory.toStdWString()), + "echo receiver could not start"); + const auto identity = model.resize_state(); + require(identity.child_pid != 0, "session has no child identity"); + require(model.configuration_identity().contains(QStringLiteral("qt-widgets")) && + model.configuration_identity().contains(QStringLiteral("reviewed-side-by-side-1.25.260930003")), + "session is not the formal Qt widget / bundled ConPTY configuration"); + await([&] { return has_text(model.screen(), "TF_ECHO_READY"); }, "echo receiver did not become ready"); + + const auto samples = sample_set(); + std::string expected_received; + for (const auto& sample : samples) { + require(text_event(terminal, QString::fromUtf8(sample.utf8.data(), static_cast(sample.utf8.size()))), + std::string(sample.id) + ": sample text was rejected by the Qt input path"); + QTest::keyClick(&terminal, Qt::Key_Return); + expected_received += sample.utf8 + "\r"; + } + finish(model); + const std::string received = read_file(receiver_path); + const std::string echoed = read_file(echo_path); + if (received != expected_received) { + std::cerr << "receiver expected=" << hex(expected_received) << " actual=" << hex(received) << '\n'; + for (size_t row = 0; row < model.screen().visible_lines().size(); ++row) { + const std::string text = screen_line(model.screen(), row); + if (!text.empty()) std::cerr << "screen[" << row << "]=" << hex(text) << '\n'; + } + } + require(received == expected_received, "independent receiver did not get the exact sample bytes"); + require(echoed == expected_echo(received), "echo source bytes are not the documented CR->CRLF echo"); + require(has_text(model.screen(), echo_begin()) && has_text(model.screen(), echo_end()), + "echoed lines did not reach the terminal"); + + // Offscreen capture of the owned display chain. + const QImage image = terminal.grab().toImage(); + require(!image.isNull() && image.width() > 0 && image.height() > 0, "offscreen capture is empty"); + require(terminal.painted_page().has_value(), "widget published no painted page"); + const auto& page = *terminal.painted_page(); + const int cell_width = terminal.cell_width(); + const int cell_height = terminal.cell_height(); + const int margin = TerminalWidget::content_margin(); + require(cell_width >= 1 && cell_height >= 1, "widget cell geometry is invalid"); + const QColor background = image.pixelColor(0, 0); + + const size_t begin_row = find_line(model.screen(), echo_begin()); + require(begin_row < model.screen().visible_lines().size(), "screen has no echo begin row"); + require(begin_row >= terminal.painted_row_offset() && + begin_row - terminal.painted_row_offset() < page.lines.size(), + "echo begin row is outside the painted page"); + const size_t display_begin = begin_row - terminal.painted_row_offset(); + + QJsonArray cases; + bool host_fallback_verified = true; + for (size_t index = 0; index < samples.size(); ++index) { + const auto& sample = samples[index]; + const size_t row = begin_row + 1 + index; + require(row < model.screen().visible_lines().size(), std::string(sample.id) + ": screen ended before its row"); + const std::string parsed = screen_line(model.screen(), row); + // The record-only sample must stay byte-exact until the parser sees it. The + // current Unicode-15 width table does not cover U+200D/U+FE0F, so the + // recovery parser replaces those scalars with U+FFFD. Accept either the + // current documented recovery result or future full support; do not decide + // a new clustering/width policy here. + const std::string zwj_recovery = + "\xf0\x9f\x91\xa9" "\xef\xbf\xbd" "\xf0\x9f\x92\xbb" "1" "\xef\xbf\xbd\xe2\x83\xa3"; + if (sample.asserted) { + if (parsed != sample.utf8) { + std::cerr << sample.id << ": row=" << row << " expected=" << hex(sample.utf8) + << " actual=" << hex(parsed) << '\n'; + } + require(parsed == sample.utf8, std::string(sample.id) + ": parsed row text changed"); + } else { + require(parsed == sample.utf8 || parsed == zwj_recovery, + std::string(sample.id) + ": parsed row is neither exact nor the documented recovery result"); + } + if (sample.asserted) check_cells(model.screen(), row, sample); + const size_t display_row = display_begin + 1 + index; + const int band_top = margin + static_cast(display_row) * cell_height; + const auto& line = model.screen().visible_lines().at(row); + // The painted page is the owned paint input; it must carry the same row the + // core publishes, or the pixel checks below would measure a different frame. + require(display_row < page.lines.size(), std::string(sample.id) + ": painted page ended before its row"); + std::string page_text; + for (const auto& cell : page.lines[display_row]) + if (cell.width) page_text += cell.text; + while (!page_text.empty() && page_text.back() == ' ') page_text.pop_back(); + require(page_text == parsed, std::string(sample.id) + ": painted page row differs from the core row"); + + // The display span of this row, taken from the literal cells for asserted + // samples and from the written/observed cells for the record-only sample. + int span_columns = 0; + if (sample.asserted) span_columns = static_cast(sample.cells.size()); + else { + for (size_t column = 0; column < line.size(); ++column) { + if (!line[column].written && line[column].marks == 0 && line[column].text == " ") continue; + span_columns = (std::max)(span_columns, static_cast(column) + (std::max)(1, line[column].width)); + } + } + require(span_columns > 0, std::string(sample.id) + ": row has no display span"); + const int x_begin = margin; + const int x_end = margin + span_columns * cell_width; + const InkBox line_ink = measure_ink(image, x_begin, x_end, band_top, band_top + cell_height, background); + + // A host without the fallback face renders no glyph at all (offscreen font + // database). That environment limitation is recorded per row, never treated + // as a passed fallback check; the bundled-font cells must always be present. + QJsonArray painted_cells; + bool row_missing_glyphs = false; + int fallback_cells = 0; + for (size_t column = 0; column < line.size(); ++column) { + const auto& cell = line[column]; + const QString glyph_text = cell_string(cell); + const bool blank = glyph_text.trimmed().isEmpty(); + if (cell.width <= 0 || blank) continue; + const int span_px = cell.width * cell_width; + const int cell_x_begin = margin + static_cast(column) * cell_width; + const int cell_x_end = cell_x_begin + span_px; + const InkBox ink = measure_ink(image, cell_x_begin, cell_x_end, band_top, band_top + cell_height, background); + const GlyphObservation glyphs = observe_glyphs(glyph_text, terminal.font()); + const bool missing = glyphs.missing > 0; + const bool bundled_face = std::find(glyphs.families.begin(), glyphs.families.end(), + terminal.font().family()) != glyphs.families.end(); + InkBox reference; + if (sample.asserted) { + if (missing) { + require(sample.host_fallback, + std::string(sample.id) + ": the bundled font is missing a required glyph"); + row_missing_glyphs = true; + } else { + // Same text/font, unclipped: proves the fallback glyph itself fits + // the declared span instead of being cut by the per-cell clip. + reference = reference_ink(glyph_text, terminal.font(), background, QColor(216, 222, 228), + span_px * 3 + 64, cell_height); + require(reference.count > 0, std::string(sample.id) + ": reference glyph has no ink"); + require(reference.left >= 0 && reference.right < span_px, + std::string(sample.id) + ": unclipped glyph ink spills out of its cell span"); + if (bundled_face) + require(glyphs.advance <= static_cast(span_px) + 1.0, + std::string(sample.id) + ": product-font glyph advance exceeds its cell span"); + } + } else if (missing) row_missing_glyphs = true; + if (!missing) { + require(ink.count > 0, std::string(sample.id) + ": an available glyph has no painted ink"); + if (!bundled_face) ++fallback_cells; + } + QJsonArray families; + for (const auto& family : glyphs.families) families.append(family); + QJsonObject cell_json{{"column", static_cast(column)}, + {"text_hex", QString::fromStdString(hex(cell.text))}, + {"width", cell.width}, + {"marks", static_cast(cell.marks)}, + {"families", families}, + {"glyph_available", !missing}, + {"glyphs", glyphs.glyphs}, + {"missing_glyphs", glyphs.missing}, + {"advance", glyphs.advance}, + {"advance_over_span", glyphs.advance > static_cast(span_px)}, + {"cell_span_px", span_px}, + {"ink_pixels", ink.count}, + {"ink_span", QJsonArray{ink.left - cell_x_begin, ink.right - cell_x_begin}}}; + if (sample.asserted) { + cell_json["bundled_face"] = bundled_face; + if (!missing) cell_json["reference_ink_span"] = QJsonArray{reference.left, reference.right}; + } + painted_cells.append(cell_json); + } + if (!row_missing_glyphs) require(line_ink.count > 0, std::string(sample.id) + ": sample row has no painted ink"); + require(line_ink.right < x_end, std::string(sample.id) + ": painted ink left its display span"); + if (sample.asserted && sample.host_fallback && row_missing_glyphs) host_fallback_verified = false; + cases.append(QJsonObject{{"id", sample.id}, + {"asserted", sample.asserted}, + {"source_hex", QString::fromStdString(hex(sample.utf8))}, + {"parsed_hex", QString::fromStdString(hex(parsed))}, + {"recovery_replaced_scalars", parsed != sample.utf8}, + {"row", static_cast(row)}, + {"host_fallback_required", sample.host_fallback}, + {"host_fallback_rendered", !row_missing_glyphs}, + {"fallback_cells", fallback_cells}, + {"cells", observed_cells(model.screen(), row)}, + {"painted_cells", painted_cells}, + {"line_ink_pixels", line_ink.count}, + {"line_ink_span", QJsonArray{line_ink.left - x_begin, line_ink.right - x_begin}}, + {"display_span_px", span_columns * cell_width}}); + } + + const QString font_path = QCoreApplication::applicationDirPath() + QStringLiteral("/assets/fonts/RobotoMono-Regular.ttf"); + const std::string bundled_font = read_file(font_path); + return QJsonObject{ + {"case", "samples-through-qt-presentation"}, + {"backend", model.configuration_identity()}, + {"font_family", terminal.font().family()}, + {"bundled_font_sha256", QString::fromStdString(sha256_hex(bundled_font))}, + {"host_fallback_verified", host_fallback_verified}, + {"font_database_families", QFontDatabase::families().size()}, + {"grid", QJsonArray{model.screen().width(), model.screen().height()}}, + {"cell_geometry", QJsonArray{cell_width, cell_height}}, + {"child_pid", static_cast(identity.child_pid)}, + {"receiver_hex", QString::fromStdString(hex(received))}, + {"receiver_sha256", QString::fromStdString(sha256_hex(received))}, + {"echo_hex", QString::fromStdString(hex(echoed))}, + {"echo_sha256", QString::fromStdString(sha256_hex(echoed))}, + {"cases", cases}, + {"presented_done", QJsonValue()}, + {"offscreen_only", true}, + {"physical_presentation", false}, + {"native_ime", false}}; +} +QJsonObject isolation_case(const QString& receiver, const QString& directory, const QJsonObject& main) { + const QString receiver_path = directory + QStringLiteral("/isolation-received.bin"); + const QString echo_path = directory + QStringLiteral("/isolation-echo.bin"); + SessionModel model; + TerminalWidget terminal(model); + focus(terminal); + require(model.start(receiver.toStdWString(), + (QStringLiteral("\"") + receiver_path + QStringLiteral("\" \"") + echo_path + + QLatin1Char('"')).toStdWString(), + directory.toStdWString()), + "isolation receiver could not start"); + const auto identity = model.resize_state(); + require(identity.child_pid != 0, "isolation session has no child identity"); + await([&] { return has_text(model.screen(), "TF_ECHO_READY"); }, "isolation receiver did not become ready"); + finish(model); + const std::string received = read_file(receiver_path); + const std::string echoed = read_file(echo_path); + require(received.empty(), "isolation session received text that was not sent to it"); + require(echoed == expected_echo(received), "isolation echo source bytes changed"); + for (size_t row = 0; row < model.screen().visible_lines().size(); ++row) { + const std::string text = screen_line(model.screen(), row); + require(text.find("TermForge") == std::string::npos && text.find("\xe6\xb1\x89") == std::string::npos && + text.find("\xf0\x9f\x91\xa9") == std::string::npos, + "sample text appeared in another session's display"); + } + require(main.value("child_pid").toInt() != static_cast(identity.child_pid), + "the two observed sessions share one child identity"); + return {{"case", "isolation"}, + {"child_pid", static_cast(identity.child_pid)}, + {"received_hex", QString::fromStdString(hex(received))}, + {"echo_sha256", QString::fromStdString(sha256_hex(echoed))}, + {"sample_text_absent", true}}; +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + char gate[3]{}; + DWORD count = 0; + BOOL owned = FALSE; + if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), gate, sizeof(gate), &count, nullptr) || count != sizeof(gate) || + std::string_view(gate, count) != "GO\n" || !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) { + std::cerr << "Qt Unicode presentation test requires its owned Job supervisor\n"; + return 2; + } + // The generic Qt smoke supervisor pins QT_QPA_FONTDIR to the bundled font so + // ASCII-only checks are deterministic. The offscreen platform builds its font + // database from that directory, so this test points it at the host font + // directory instead: the product font is still loaded by absolute path inside + // TerminalWidget, and the CJK/emoji fallback faces must be the real host faces. + // The offscreen capture therefore proves local glyph/fallback selection only; + // it is not physical presentation. + const QString host_fonts = qEnvironmentVariable("WINDIR") + QStringLiteral("/Fonts"); + qputenv("QT_QPA_FONTDIR", QDir(host_fonts).exists() ? QDir::toNativeSeparators(host_fonts).toUtf8() + : qgetenv("QT_QPA_FONTDIR")); + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + try { + const auto artifacts = qEnvironmentVariable("TERMFORGE_TEST_ARTIFACTS"); + if (!artifacts.isEmpty()) require(QDir().mkpath(artifacts), "artifact directory could not be created"); + QTemporaryDir workspace(artifacts.isEmpty() ? QString() : artifacts + QStringLiteral("/qt-unicode-render-XXXXXX")); + require(workspace.isValid(), "owned fixture workspace could not be created"); + if (!artifacts.isEmpty()) workspace.setAutoRemove(false); + const auto receiver = QCoreApplication::applicationDirPath() + QStringLiteral("/shell_unicode_echo_client.exe"); + require(QFile::exists(receiver), "independent echo receiver is missing"); + const QJsonObject main = presentation_case(receiver, workspace.path()); + const QJsonObject isolation = isolation_case(receiver, workspace.path(), main); + const QJsonObject result{{"schema", "termforge-qt-unicode-presentation-run-v1"}, + {"main", main}, + {"isolation", isolation}, + {"presented_done", QJsonValue()}, + {"offscreen_only", true}}; + QFile saved(workspace.path() + QStringLiteral("/sample.json")); + require(saved.open(QIODevice::WriteOnly) && saved.write(QJsonDocument(result).toJson()) > 0, + "sample could not be retained"); + std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << "Qt Unicode presentation: " << error.what() << '\n'; + return 1; + } +}