From fbe4c4caf3ac050a4dc0c4ea61c28b0e0febc1d0 Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:14:32 +0800 Subject: [PATCH 1/2] feat(qt): verify fixed Unicode samples through Qt presentation (#30) Add qt_unicode_render: a fixed five-sample set (ASCII, CJK, supplementary plane, combining mark, record-only ZWJ/keycap) goes through the formal Qt key path into a real child, whose ReadConsoleW receive file is the independent byte oracle; the child echoes the same bytes with CR->CRLF and the widget paints the parsed Screen offscreen. The test asserts exact receive/echo bytes, literal Cell/width/marks, per-cell painted ink, actual RawFont fallback faces and missing-glyph count, and an unclipped reference ink bound; a second empty session observes target isolation. Measured locally: CJK renders with host Alibaba PuHuiTi, two emoji with Segoe UI Emoji (advance 21 px over an 18 px span but reference ink 3..17 stays inside), the combining mark keeps marks=1, and U+200D/U+FE0F are still replaced by U+FFFD by the recovery parser (recorded gap, no new clustering/width policy). The capture is offscreen only: presented_done=null, not physical presentation or native IME. Add the independent echo fixture and the cell-geometry diagnostics, register the target and runtime manifest in the Qt CI job, and record the evidence. Qt formal-path CTest 6/6; core CTest 41/41 including the 15 Unicode parser tests. #30/#155 stay open. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- .github/workflows/qt-cpp.yml | 7 +- CMakeLists.txt | 20 + docs/EVIDENCE.md | 1 + docs/FEATURES.md | 2 +- .../README.md | 67 +++ experiments/shell/unicode_echo_client.cpp | 123 ++++ src/qt/README.md | 8 +- src/qt/terminal_widget.hpp | 4 + src/qt/unicode_render_test.cpp | 556 ++++++++++++++++++ 9 files changed, 782 insertions(+), 6 deletions(-) create mode 100644 experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md create mode 100644 experiments/shell/unicode_echo_client.cpp create mode 100644 src/qt/unicode_render_test.cpp 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..52339946 --- /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`,合计 10.50 s);新测试连跑两次内容一致(receiver/echo SHA-256 相同,仅 child pid 不同)。默认/核心配置 CTest **41/41**(28.49 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/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; + } +} From c3888ff52fef4c0a10c936ea856ab621fa59a8f9 Mon Sep 17 00:00:00 2001 From: JavaZeroo-Bot <133958205+JavaZeroo-Bot@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:17:43 +0800 Subject: [PATCH 2/2] docs(evidence): record the Qt Unicode presentation validation (#30) Record the final two-run and regression results for qt_unicode_render in validation.json: exact receive/echo hashes, per-cell glyph families, missing-glyph counts, advance and ink spans, the ZWJ/keycap recovery gap, and the fixed dependency/source/binary identities. Also align the report's measured timings with the final runs. The recorded implementation sources still match the implementation commit; this follow-up changes only documentation. #30/#155 stay open. Co-authored-by: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> --- .../README.md | 2 +- .../validation.json | 957 ++++++++++++++++++ 2 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 experiments/shell/results/2026-11-04-qt-unicode-presentation/validation.json 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 index 52339946..5724f11b 100644 --- a/experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md +++ b/experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md @@ -58,7 +58,7 @@ cmake --build build/qt-cpp --config Release --target qt_unicode_render_test --pa 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`,合计 10.50 s);新测试连跑两次内容一致(receiver/echo SHA-256 相同,仅 child pid 不同)。默认/核心配置 CTest **41/41**(28.49 s,含 `core` 的 16 项核心与 15 项 Unicode Python 测试、`keyboard`、`recovery`、`resize_replay_contract` 及全部 `*_core`)。完整源码/二进制/运行文件身份见 [validation.json](validation.json),不发布 SDK、部署目录或原始日志。 +本次同源码结果: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、部署目录或原始日志。 ## 边界 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." + ] +}