From 612da2d09cdfd6f92cfc9cf8e3689effb92414fe Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 23 Jun 2026 08:44:24 +0800 Subject: [PATCH] Add rich clipboard (CF_HTML build/parse/get/set) --- README/WHATS_NEW_zh-CN.md | 6 + README/WHATS_NEW_zh-TW.md | 6 + WHATS_NEW.md | 6 + .../doc/new_features/v144_features_doc.rst | 44 ++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v144_features_doc.rst | 38 +++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 8 + .../gui/script_builder/command_schema.py | 13 ++ .../utils/executor/action_executor.py | 17 +++ .../utils/mcp_server/tools/_factories.py | 28 +++- .../utils/mcp_server/tools/_handlers.py | 10 ++ .../utils/rich_clipboard/__init__.py | 7 + .../utils/rich_clipboard/rich_clipboard.py | 142 ++++++++++++++++++ .../headless/test_rich_clipboard_batch.py | 76 ++++++++++ 15 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v144_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v144_features_doc.rst create mode 100644 je_auto_control/utils/rich_clipboard/__init__.py create mode 100644 je_auto_control/utils/rich_clipboard/rich_clipboard.py create mode 100644 test/unit_test/headless/test_rich_clipboard_batch.py diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 44da7119..3d816aa7 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 丰富剪贴板(HTML / CF_HTML) + +把*格式化*的 HTML 复制粘贴到 Word / Outlook。完整参考:[`docs/source/Zh/doc/new_features/v144_features_doc.rst`](../docs/source/Zh/doc/new_features/v144_features_doc.rst)。 + +- **`build_cf_html` / `parse_cf_html` / `set_clipboard_html` / `get_clipboard_html`**(`AC_set_clipboard_html`、`AC_get_clipboard_html`):基础剪贴板只处理纯文字 + 图像——富文字粘贴需要 `CF_HTML`,其字节偏移标头(`StartHTML`/`EndHTML`/`StartFragment`/`EndFragment`)极易出错。`build_cf_html`/`parse_cf_html` 以纯 Python 计算与还原它(往返测试、多字节 UTF-8 正确);`set/get_clipboard_html` 将其包装于 Win32 剪贴板(含纯文字后备)。字节偏移运算可无头测试;只有 I/O 为 Windows。 + ## 本次更新 (2026-06-23) — 可串接 / 可过滤的候选定位器 用链式调用细化已定位的元素:`.within(panel).filter(has_text="Delete").nth(1)`。完整参考:[`docs/source/Zh/doc/new_features/v143_features_doc.rst`](../docs/source/Zh/doc/new_features/v143_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index 0f4c5080..267c06c2 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 豐富剪貼簿(HTML / CF_HTML) + +把*格式化*的 HTML 複製貼上到 Word / Outlook。完整參考:[`docs/source/Zh/doc/new_features/v144_features_doc.rst`](../docs/source/Zh/doc/new_features/v144_features_doc.rst)。 + +- **`build_cf_html` / `parse_cf_html` / `set_clipboard_html` / `get_clipboard_html`**(`AC_set_clipboard_html`、`AC_get_clipboard_html`):基礎剪貼簿只處理純文字 + 影像——富文字貼上需要 `CF_HTML`,其位元組偏移標頭(`StartHTML`/`EndHTML`/`StartFragment`/`EndFragment`)極易出錯。`build_cf_html`/`parse_cf_html` 以純 Python 計算與還原它(往返測試、多位元組 UTF-8 正確);`set/get_clipboard_html` 將其包裝於 Win32 剪貼簿(含純文字後備)。位元組偏移運算可無頭測試;只有 I/O 為 Windows。 + ## 本次更新 (2026-06-23) — 可串接 / 可過濾的候選定位器 用鏈式呼叫細化已定位的元素:`.within(panel).filter(has_text="Delete").nth(1)`。完整參考:[`docs/source/Zh/doc/new_features/v143_features_doc.rst`](../docs/source/Zh/doc/new_features/v143_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index d0e7054a..ec24f090 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-23) — Rich Clipboard (HTML / CF_HTML) + +Copy and paste *formatted* HTML into Word / Outlook. Full reference: [`docs/source/Eng/doc/new_features/v144_features_doc.rst`](docs/source/Eng/doc/new_features/v144_features_doc.rst). + +- **`build_cf_html` / `parse_cf_html` / `set_clipboard_html` / `get_clipboard_html`** (`AC_set_clipboard_html`, `AC_get_clipboard_html`): the base clipboard handles plain text + image only — rich paste needs `CF_HTML`, whose byte-offset header (`StartHTML`/`EndHTML`/`StartFragment`/`EndFragment`) is famously error-prone. `build_cf_html`/`parse_cf_html` compute and recover it in pure Python (round-trip tested, correct across multi-byte UTF-8); `set/get_clipboard_html` wrap them over the Win32 clipboard (with a plain-text fallback). Byte-offset math is headless-testable; only the I/O is Windows. + ## What's new (2026-06-23) — Composable / Filtered Candidate Locators Refine located elements with a chain: `.within(panel).filter(has_text="Delete").nth(1)`. Full reference: [`docs/source/Eng/doc/new_features/v143_features_doc.rst`](docs/source/Eng/doc/new_features/v143_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v144_features_doc.rst b/docs/source/Eng/doc/new_features/v144_features_doc.rst new file mode 100644 index 00000000..7f7e0fa6 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v144_features_doc.rst @@ -0,0 +1,44 @@ +Rich Clipboard — HTML (CF_HTML) +=============================== + +The base ``clipboard`` module handles plain text (``CF_UNICODETEXT``) and image +(``CF_DIB``) only. Pasting *formatted* content into Word / Outlook / a rich editor +needs the ``CF_HTML`` format, whose ``Version / StartHTML / EndHTML / StartFragment / +EndFragment`` **byte-offset** header is notoriously error-prone to build by hand. +``build_cf_html`` / ``parse_cf_html`` compute and recover that header in pure Python +(a fully unit-tested round-trip, correct across multi-byte UTF-8), and +``set_clipboard_html`` / ``get_clipboard_html`` wrap them over the Win32 clipboard. + +The byte-offset math is platform-independent and headless-testable; only the actual +clipboard I/O is Windows (raising ``RuntimeError`` elsewhere, like the base module). +Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import (build_cf_html, parse_cf_html, + set_clipboard_html, get_clipboard_html) + + set_clipboard_html("Bold and italic", + fragment_plaintext="Bold and italic") # Windows + html = get_clipboard_html() # Windows + + # The pure pieces work anywhere (e.g. to pre-build a payload): + payload = build_cf_html("

hello

") # bytes, valid CF_HTML + assert parse_cf_html(payload) == "

hello

" + +``build_cf_html`` returns valid ``CF_HTML`` UTF-8 bytes whose offsets point exactly at +the fragment; ``parse_cf_html`` recovers the fragment from bytes or text (preferring +the comment markers, falling back to the byte offsets). ``set_clipboard_html`` also +seeds plain text via ``fragment_plaintext`` so apps that ignore HTML still paste +something. + +Executor commands +----------------- + +``AC_set_clipboard_html`` (``html`` / ``fragment_plaintext`` → ``{set, length}``) and +``AC_get_clipboard_html`` (→ ``{found, html}``). They are exposed as the MCP tools +``ac_set_clipboard_html`` / ``ac_get_clipboard_html`` and as Script Builder commands +under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index ea420815..9a52e556 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -166,6 +166,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v141_features_doc doc/new_features/v142_features_doc doc/new_features/v143_features_doc + doc/new_features/v144_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v144_features_doc.rst b/docs/source/Zh/doc/new_features/v144_features_doc.rst new file mode 100644 index 00000000..a2da9610 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v144_features_doc.rst @@ -0,0 +1,38 @@ +豐富剪貼簿——HTML(CF_HTML) +============================= + +基礎 ``clipboard`` 模組只處理純文字(``CF_UNICODETEXT``)與影像(``CF_DIB``)。將*格式化*內容貼進 Word / +Outlook / 富文字編輯器需要 ``CF_HTML`` 格式,其 ``Version / StartHTML / EndHTML / StartFragment / +EndFragment`` **位元組偏移**標頭以手寫極易出錯。``build_cf_html`` / ``parse_cf_html`` 以純 Python 計算與還原 +該標頭(完整單元測試的往返,且在多位元組 UTF-8 下正確),而 ``set_clipboard_html`` / ``get_clipboard_html`` +將其包裝於 Win32 剪貼簿之上。 + +位元組偏移運算與平台無關且可無頭測試;只有實際的剪貼簿 I/O 為 Windows(在其他平台丟出 ``RuntimeError``, +與基礎模組一致)。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import (build_cf_html, parse_cf_html, + set_clipboard_html, get_clipboard_html) + + set_clipboard_html("Bold and italic", + fragment_plaintext="Bold and italic") # Windows + html = get_clipboard_html() # Windows + + # 純函式在任何平台皆可用(例如預先建立 payload): + payload = build_cf_html("

hello

") # bytes,有效 CF_HTML + assert parse_cf_html(payload) == "

hello

" + +``build_cf_html`` 回傳偏移精確指向片段的有效 ``CF_HTML`` UTF-8 位元組;``parse_cf_html`` 從 bytes 或文字還原片段 +(優先用註解標記,退而用位元組偏移)。``set_clipboard_html`` 也會以 ``fragment_plaintext`` 設定純文字,讓忽略 +HTML 的程式仍能貼上內容。 + +執行器命令 +---------- + +``AC_set_clipboard_html``(``html`` / ``fragment_plaintext`` → ``{set, length}``)與 ``AC_get_clipboard_html`` +(→ ``{found, html}``)。它們以 MCP 工具 ``ac_set_clipboard_html`` / ``ac_get_clipboard_html`` 以及 Script +Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 6e59c70c..d35b4ac7 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -166,6 +166,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v141_features_doc doc/new_features/v142_features_doc doc/new_features/v143_features_doc + doc/new_features/v144_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 34e39506..a4d59a66 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -335,6 +335,10 @@ ) # Composable / filtered candidate locators (chained-locator idiom) from je_auto_control.utils.locator_chain import Candidates, from_boxes +# Rich clipboard formats — HTML (CF_HTML) build / parse / get / set +from je_auto_control.utils.rich_clipboard import ( + build_cf_html, get_clipboard_html, parse_cf_html, set_clipboard_html, +) # CI workflow annotations (GitHub Actions) from je_auto_control.utils.ci_annotations import ( emit_annotations, format_annotation, @@ -1187,6 +1191,10 @@ def start_autocontrol_gui(*args, **kwargs): "to_be_stable", "Candidates", "from_boxes", + "build_cf_html", + "parse_cf_html", + "get_clipboard_html", + "set_clipboard_html", "emit_annotations", "format_annotation", "ClipboardHistory", "default_clipboard_history", "analyze_heal_log", "heal_stats", "scan_secrets", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index dee3671c..5bd7d1ff 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1044,6 +1044,19 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: def _add_misc_specs(specs: List[CommandSpec]) -> None: _add_native_control_specs(specs) + specs.append(CommandSpec( + "AC_set_clipboard_html", "Data", "Set Clipboard HTML", + fields=( + FieldSpec("html", FieldType.STRING, + placeholder="Bold rich text"), + FieldSpec("fragment_plaintext", FieldType.STRING, optional=True), + ), + description="Put rich HTML on the clipboard (CF_HTML, Windows).", + )) + specs.append(CommandSpec( + "AC_get_clipboard_html", "Data", "Get Clipboard HTML", + description="Read the clipboard's HTML fragment (CF_HTML, Windows).", + )) specs.append(CommandSpec( "AC_watchdog_add", "Flow", "Watchdog: Add Popup Rule", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index b26b94e5..874f1477 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3661,6 +3661,21 @@ def _locate_chain(boxes: Any, ops: Any = None) -> Dict[str, Any]: "center": candidates.center()} +def _set_clipboard_html(html: str, fragment_plaintext: Any = None + ) -> Dict[str, Any]: + """Adapter: put an HTML fragment on the clipboard as CF_HTML (Windows).""" + from je_auto_control.utils.rich_clipboard import set_clipboard_html + set_clipboard_html(str(html), fragment_plaintext=fragment_plaintext) + return {"set": True, "length": len(str(html))} + + +def _get_clipboard_html() -> Dict[str, Any]: + """Adapter: read the clipboard's HTML fragment (Windows).""" + from je_auto_control.utils.rich_clipboard import get_clipboard_html + html = get_clipboard_html() + return {"found": html is not None, "html": html} + + def _with_modifiers(modifiers: Any, actions: Any) -> Dict[str, Any]: """Adapter: run nested actions while modifier keys are held down.""" import json @@ -5402,6 +5417,8 @@ def __init__(self): "AC_find_separators": _find_separators, "AC_expect_poll": _expect_poll, "AC_locate_chain": _locate_chain, + "AC_set_clipboard_html": _set_clipboard_html, + "AC_get_clipboard_html": _get_clipboard_html, "AC_tile_rect": _tile_rect, "AC_grid_rects": _grid_rects, "AC_cascade_rects": _cascade_rects, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 7e772c54..116fd9d7 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3060,6 +3060,32 @@ def locator_chain_tools() -> List[MCPTool]: ] +def rich_clipboard_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_set_clipboard_html", + description=("Put an HTML fragment on the clipboard as CF_HTML for rich " + "paste into Word / Outlook / rich editors (Windows). " + "'fragment_plaintext' is also set as plain text. Returns " + "{set, length}."), + input_schema=schema({ + "html": {"type": "string"}, + "fragment_plaintext": {"type": "string"}}, + required=["html"]), + handler=h.set_clipboard_html, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_get_clipboard_html", + description=("Read the clipboard's HTML fragment (CF_HTML, Windows). " + "Returns {found, html}."), + input_schema=schema({}, required=[]), + handler=h.get_clipboard_html, + annotations=READ_ONLY, + ), + ] + + def ssim_tools() -> List[MCPTool]: return [ MCPTool( @@ -6565,7 +6591,7 @@ def media_assert_tools() -> List[MCPTool]: window_layout_tools, window_arrange_tools, preprocess_tools, monitor_layout_tools, actionability_tools, element_parse_tools, hsv_segment_tools, text_regions_tools, edge_lines_tools, expect_poll_tools, - locator_chain_tools, plugin_sdk_tools, governance_tools, + locator_chain_tools, rich_clipboard_tools, plugin_sdk_tools, governance_tools, credential_lease_tools, egress_tools, approval_testing_tools, trajectory_eval_tools, compliance_tools, agent_trace_tools, video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 0335451c..b6378854 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2237,6 +2237,16 @@ def locate_chain(boxes, ops=None): return _locate_chain(boxes, ops) +def set_clipboard_html(html, fragment_plaintext=None): + from je_auto_control.utils.executor.action_executor import _set_clipboard_html + return _set_clipboard_html(html, fragment_plaintext) + + +def get_clipboard_html(): + from je_auto_control.utils.executor.action_executor import _get_clipboard_html + return _get_clipboard_html() + + def detect_drift(reference, current, threshold=0.25, bins=10): from je_auto_control.utils.executor.action_executor import _detect_drift return _detect_drift(reference, current, threshold, bins) diff --git a/je_auto_control/utils/rich_clipboard/__init__.py b/je_auto_control/utils/rich_clipboard/__init__.py new file mode 100644 index 00000000..09f3548d --- /dev/null +++ b/je_auto_control/utils/rich_clipboard/__init__.py @@ -0,0 +1,7 @@ +"""Rich clipboard formats — HTML (CF_HTML) build / parse / get / set.""" +from je_auto_control.utils.rich_clipboard.rich_clipboard import ( + build_cf_html, get_clipboard_html, parse_cf_html, set_clipboard_html, +) + +__all__ = ["build_cf_html", "get_clipboard_html", "parse_cf_html", + "set_clipboard_html"] diff --git a/je_auto_control/utils/rich_clipboard/rich_clipboard.py b/je_auto_control/utils/rich_clipboard/rich_clipboard.py new file mode 100644 index 00000000..0719d486 --- /dev/null +++ b/je_auto_control/utils/rich_clipboard/rich_clipboard.py @@ -0,0 +1,142 @@ +"""Rich clipboard formats — HTML (CF_HTML) get / set, for rich paste into Office. + +The base ``clipboard`` module handles plain text (CF_UNICODETEXT) and image (CF_DIB) +only; pasting formatted content into Word / Outlook / a rich editor needs the +``CF_HTML`` format, whose ``Version / StartHTML / EndHTML / StartFragment / +EndFragment`` *byte-offset* header is notoriously error-prone to build. This module's +``build_cf_html`` / ``parse_cf_html`` compute and recover that header in pure Python — +fully unit-testable round-trip — and the Windows ``get_clipboard_html`` / +``set_clipboard_html`` wrap them over the Win32 clipboard. + +The byte-offset math is platform-independent and headless-testable; only the actual +clipboard I/O is Win32 (raising ``RuntimeError`` elsewhere, like the base module). +Imports no ``PySide6``. +""" +import re +import sys +from typing import Optional + +_HEADER = ("Version:0.9\r\n" + "StartHTML:{:010d}\r\n" + "EndHTML:{:010d}\r\n" + "StartFragment:{:010d}\r\n" + "EndFragment:{:010d}\r\n") +_PRE = "" +_POST = "" +_START_MARK = "" +_END_MARK = "" +_HTML_FORMAT_NAME = "HTML Format" + + +def build_cf_html(html: str) -> bytes: + """Wrap an HTML fragment in a valid ``CF_HTML`` clipboard payload (UTF-8 bytes). + + The fixed-width 10-digit offsets make the header length constant, so the byte + offsets can be computed in a single pass. + """ + if not isinstance(html, str): + raise TypeError("build_cf_html expects a str") + header_len = len(_HEADER.format(0, 0, 0, 0).encode("utf-8")) + start_html = header_len + start_fragment = start_html + len(_PRE.encode("utf-8")) + end_fragment = start_fragment + len(html.encode("utf-8")) + end_html = end_fragment + len(_POST.encode("utf-8")) + header = _HEADER.format(start_html, end_html, start_fragment, end_fragment) + return (header + _PRE + html + _POST).encode("utf-8") + + +def _header_offset(text: str, name: str) -> Optional[int]: + match = re.search(rf"{name}:(\d+)", text) + return int(match.group(1)) if match else None + + +def parse_cf_html(blob) -> str: + """Extract the HTML fragment from a ``CF_HTML`` payload (bytes or str). + + Prefers the ``StartFragment`` / ``EndFragment`` comment markers, falling back to + the header's byte offsets. + """ + raw = bytes(blob) if isinstance(blob, (bytes, bytearray)) else None + text = (raw.decode("utf-8", "replace") if raw is not None else str(blob)) + start = text.find(_START_MARK) + end = text.find(_END_MARK) + if start != -1 and end != -1: + return text[start + len(_START_MARK):end] + start_offset = _header_offset(text, "StartFragment") + end_offset = _header_offset(text, "EndFragment") + if raw is not None and start_offset is not None and end_offset is not None: + return raw[start_offset:end_offset].decode("utf-8", "replace") + return text + + +def set_clipboard_html(html: str, *, fragment_plaintext: Optional[str] = None) -> None: + """Put an HTML fragment on the clipboard as ``CF_HTML`` (Windows only). + + ``fragment_plaintext`` is also placed as plain text so apps that ignore HTML still + paste something. Raises ``RuntimeError`` on non-Windows platforms. + """ + if not isinstance(html, str): + raise TypeError("set_clipboard_html expects a str") + if not sys.platform.startswith("win"): + raise RuntimeError("set_clipboard_html is only supported on Windows") + _win_set_html(build_cf_html(html), fragment_plaintext) + + +def get_clipboard_html() -> Optional[str]: + """Return the clipboard's HTML fragment, or ``None`` (Windows only).""" + if not sys.platform.startswith("win"): + raise RuntimeError("get_clipboard_html is only supported on Windows") + blob = _win_get_html() + return parse_cf_html(blob) if blob is not None else None + + +def _html_format_id(): + import ctypes + return ctypes.windll.user32.RegisterClipboardFormatW(_HTML_FORMAT_NAME) + + +def _win_set_html(cf_html: bytes, fragment_plaintext: Optional[str]) -> None: + import ctypes + from ctypes import wintypes + from je_auto_control.utils.clipboard.clipboard import set_clipboard + user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32 + kernel32.GlobalAlloc.restype = wintypes.HGLOBAL + kernel32.GlobalLock.restype = ctypes.c_void_p + if fragment_plaintext is not None: + set_clipboard(fragment_plaintext) # seeds CF_UNICODETEXT first + if not user32.OpenClipboard(None): + raise RuntimeError("OpenClipboard failed") + try: + if fragment_plaintext is None: + user32.EmptyClipboard() + handle = kernel32.GlobalAlloc(0x0002, len(cf_html) + 1) + if not handle: + raise RuntimeError("GlobalAlloc failed") + pointer = kernel32.GlobalLock(handle) + ctypes.memmove(pointer, cf_html + b"\x00", len(cf_html) + 1) + kernel32.GlobalUnlock(handle) + if not user32.SetClipboardData(_html_format_id(), handle): + raise RuntimeError("SetClipboardData(CF_HTML) failed") + finally: + user32.CloseClipboard() + + +def _win_get_html() -> Optional[bytes]: + import ctypes + from ctypes import wintypes + user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32 + user32.GetClipboardData.restype = wintypes.HANDLE + kernel32.GlobalLock.restype = ctypes.c_void_p + if not user32.OpenClipboard(None): + raise RuntimeError("OpenClipboard failed") + try: + handle = user32.GetClipboardData(_html_format_id()) + if not handle: + return None + pointer = kernel32.GlobalLock(handle) + size = kernel32.GlobalSize(handle) + data = ctypes.string_at(pointer, size) + kernel32.GlobalUnlock(handle) + return data.split(b"\x00", 1)[0] + finally: + user32.CloseClipboard() diff --git a/test/unit_test/headless/test_rich_clipboard_batch.py b/test/unit_test/headless/test_rich_clipboard_batch.py new file mode 100644 index 00000000..bb055ad0 --- /dev/null +++ b/test/unit_test/headless/test_rich_clipboard_batch.py @@ -0,0 +1,76 @@ +"""Headless tests for rich (CF_HTML) clipboard. No Qt; I/O is Windows-only.""" +import re +import sys + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.rich_clipboard import ( + build_cf_html, get_clipboard_html, parse_cf_html, set_clipboard_html, +) + + +def test_build_offsets_point_at_fragment(): + html = "Bold & café 文字" # multibyte UTF-8 + blob = build_cf_html(html) + assert isinstance(blob, bytes) + text = blob.decode("utf-8") + start = int(re.search(r"StartFragment:(\d+)", text).group(1)) + end = int(re.search(r"EndFragment:(\d+)", text).group(1)) + assert blob[start:end].decode("utf-8") == html # byte offsets are exact + + +def test_round_trip_via_markers(): + html = "

hello world

" + assert parse_cf_html(build_cf_html(html)) == html + + +def test_parse_marker_only_payload(): + payload = "X" + assert parse_cf_html(payload) == "X" + + +def test_parse_offset_fallback_without_markers(): + fragment = "FRAG" + body = "" + fragment + "" + prefix = "StartFragment:{:010d}\r\nEndFragment:{:010d}\r\n".format( + len(""), len("") + len(fragment)) + # offsets are relative to the whole payload, so prepend the header length + header_len = len(prefix.encode("utf-8")) + payload = ("StartFragment:{:010d}\r\nEndFragment:{:010d}\r\n".format( + header_len + len(""), header_len + len("") + len(fragment)) + + body).encode("utf-8") + assert parse_cf_html(payload) == fragment + + +def test_build_rejects_non_string(): + with pytest.raises(TypeError): + build_cf_html(b"bytes") + + +@pytest.mark.skipif(sys.platform.startswith("win"), + reason="HTML clipboard I/O is supported on Windows") +def test_io_raises_off_windows(): + with pytest.raises(RuntimeError): + set_clipboard_html("x") + with pytest.raises(RuntimeError): + get_clipboard_html() + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_set_clipboard_html", "AC_get_clipboard_html"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_set_clipboard_html", "ac_get_clipboard_html"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_set_clipboard_html", "AC_get_clipboard_html"} <= specs + + +def test_facade_exports(): + for attr in ("build_cf_html", "parse_cf_html", "get_clipboard_html", + "set_clipboard_html"): + assert hasattr(ac, attr) and attr in ac.__all__