diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 15cd7df5..47ad558c 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-24) — Rich Clipboard Formats (RTF and CSV/TSV) + +Put styled text and tables on the clipboard for cross-app paste into Word and Excel. Full reference: [`docs/source/Eng/doc/new_features/v185_features_doc.rst`](docs/source/Eng/doc/new_features/v185_features_doc.rst). + +- **`build_rtf` / `rtf_to_text` / `rows_to_csv` / `csv_to_rows` + `set_clipboard_rtf` / `get_clipboard_rtf` / `set_clipboard_csv` / `get_clipboard_csv`** (`AC_set_clipboard_rtf`, `AC_get_clipboard_rtf`, `AC_set_clipboard_csv`, `AC_get_clipboard_csv`): `rich_clipboard` added CF_HTML, but RTF (the format rich editors accept) and the `Csv` format Excel reads were still missing. This adds both: `build_rtf`/`rtf_to_text` build and strip RTF control words and `\uNNNN` / `\'XX` escapes in pure Python (fully unit-testable round-trip), and `rows_to_csv`/`csv_to_rows` wrap the stdlib `csv` module (delimiter-parametrised, so `\t` gives TSV). The codecs are platform-independent; the Win32 get/set share one generic byte-transfer helper, and the sets seed plain text so plain editors still paste. No `PySide6`. + ## What's new (2026-06-24) — Keyboard Focus Order (Tab sequence / WCAG audit / set-focus) Reason about keyboard navigation: the Tab order, a WCAG focus-order audit, and set-focus. Full reference: [`docs/source/Eng/doc/new_features/v184_features_doc.rst`](docs/source/Eng/doc/new_features/v184_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v185_features_doc.rst b/docs/source/Eng/doc/new_features/v185_features_doc.rst new file mode 100644 index 00000000..134cf3fd --- /dev/null +++ b/docs/source/Eng/doc/new_features/v185_features_doc.rst @@ -0,0 +1,49 @@ +Rich Clipboard Formats — RTF and CSV/TSV +======================================== + +``rich_clipboard`` added ``CF_HTML`` for rich paste into Word / Outlook, but two +other cross-application clipboard formats were still missing: + +* **RTF** (``"Rich Text Format"``) — the format almost every rich editor accepts + for styled paste. ``build_rtf`` / ``rtf_to_text`` build and strip RTF control + words and ``\uNNNN`` / ``\'XX`` escapes in pure Python, with a fully + unit-testable round-trip. +* **CSV / TSV** (the registered ``"Csv"`` format Excel reads) — ``rows_to_csv`` / + ``csv_to_rows`` are a thin, delimiter-parametrised wrapper over the stdlib + ``csv`` module, so a table can be put on / read off the clipboard. + +The codecs are platform-independent and headless-testable; only the actual +clipboard I/O is Win32 (raising ``RuntimeError`` elsewhere, like the base +``clipboard`` module), and the byte transfer is a single generic helper shared by +both formats. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import (build_rtf, rtf_to_text, rows_to_csv, + csv_to_rows, set_clipboard_rtf, set_clipboard_csv) + + rtf = build_rtf("Hello\nWorld") # minimal valid RTF document + rtf_to_text(rtf) # -> "Hello\nWorld" + + rows_to_csv([["a", "b"], ["1", "2"]]) # 'a,b\r\n1,2\r\n' + csv_to_rows("a,b\r\n1,2\r\n") # [["a", "b"], ["1", "2"]] + + set_clipboard_rtf("Paste me as styled text") # Windows + set_clipboard_csv([["Name", "Qty"], ["Pen", "3"]], delimiter="\t") # TSV + +``build_rtf`` escapes braces / backslashes, turns newlines into ``\par`` and +non-ASCII characters into ``\uNNNN?`` escapes (the output is pure ASCII). +``set_clipboard_rtf`` / ``set_clipboard_csv`` also seed plain text by default so +plain editors still paste something; ``get_clipboard_rtf`` returns the raw RTF +string (feed it to ``rtf_to_text``) and ``get_clipboard_csv`` returns rows. + +Executor commands +----------------- + +``AC_set_clipboard_rtf`` / ``AC_get_clipboard_rtf`` / ``AC_set_clipboard_csv`` / +``AC_get_clipboard_csv`` (the sets take ``text`` / ``rows`` + ``delimiter``). They +are exposed as the matching ``ac_*`` MCP tools (the sets side-effect-only, the +gets read-only) and as Script Builder commands under **Data**. diff --git a/docs/source/Zh/doc/new_features/v185_features_doc.rst b/docs/source/Zh/doc/new_features/v185_features_doc.rst new file mode 100644 index 00000000..8d47a2ca --- /dev/null +++ b/docs/source/Zh/doc/new_features/v185_features_doc.rst @@ -0,0 +1,44 @@ +豐富剪貼簿格式——RTF 與 CSV/TSV +============================== + +``rich_clipboard`` 已加入 ``CF_HTML`` 以便把豐富內容貼進 Word / Outlook,但仍缺少另外兩種 +跨應用程式的剪貼簿格式: + +* **RTF**(``"Rich Text Format"``)——幾乎每個豐富編輯器都接受、用於樣式貼上的格式。 + ``build_rtf`` / ``rtf_to_text`` 以純 Python 建立與剝除 RTF 控制字與 ``\uNNNN`` / ``\'XX`` + 轉義,並具備完全可單元測試的往返。 +* **CSV / TSV**(Excel 讀取的已註冊 ``"Csv"`` 格式)——``rows_to_csv`` / ``csv_to_rows`` 是對 + 標準庫 ``csv`` 模組的薄包裝(可指定分隔符),讓表格能放上 / 讀下剪貼簿。 + +這些編解碼器與平台無關且可無頭測試;只有實際的剪貼簿 I/O 為 Win32(在其他平台拋出 +``RuntimeError``,與基礎 ``clipboard`` 模組一致),且位元組傳輸是兩種格式共用的單一泛型輔助 +函式。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import (build_rtf, rtf_to_text, rows_to_csv, + csv_to_rows, set_clipboard_rtf, set_clipboard_csv) + + rtf = build_rtf("Hello\nWorld") # 最小的有效 RTF 文件 + rtf_to_text(rtf) # -> "Hello\nWorld" + + rows_to_csv([["a", "b"], ["1", "2"]]) # 'a,b\r\n1,2\r\n' + csv_to_rows("a,b\r\n1,2\r\n") # [["a", "b"], ["1", "2"]] + + set_clipboard_rtf("以樣式文字貼上我") # Windows + set_clipboard_csv([["Name", "Qty"], ["Pen", "3"]], delimiter="\t") # TSV + +``build_rtf`` 會轉義大括號 / 反斜線,把換行轉為 ``\par``,並把非 ASCII 字元轉為 ``\uNNNN?`` +轉義(輸出為純 ASCII)。``set_clipboard_rtf`` / ``set_clipboard_csv`` 預設也會種入純文字,讓 +純文字編輯器仍能貼上內容;``get_clipboard_rtf`` 回傳原始 RTF 字串(再餵給 ``rtf_to_text``), +``get_clipboard_csv`` 回傳列。 + +執行器指令 +---------- + +``AC_set_clipboard_rtf`` / ``AC_get_clipboard_rtf`` / ``AC_set_clipboard_csv`` / +``AC_get_clipboard_csv``(set 取 ``text`` / ``rows`` 加 ``delimiter``)。皆以對應的 ``ac_*`` +MCP 工具(set 為僅副作用、get 為唯讀)及 Script Builder 指令(位於 **Data** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index d912ba96..edb09043 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -66,6 +66,11 @@ from je_auto_control.utils.focus_order import ( audit_focus_order, focus_control, is_interactive_role, tab_order, ) +# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set +from je_auto_control.utils.clipboard_rich_formats import ( + build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, + rtf_to_text, set_clipboard_csv, set_clipboard_rtf, +) # VLM element locator (headless) from je_auto_control.utils.vision import ( VLMNotAvailableError, click_by_description, locate_by_description, @@ -1634,6 +1639,9 @@ def start_autocontrol_gui(*args, **kwargs): "control_type_name", "humanize_role", "humanize_tree", "assign_node_paths", "find_by_path", "is_interactive_role", "tab_order", "audit_focus_order", "focus_control", + "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", + "set_clipboard_rtf", "get_clipboard_rtf", + "set_clipboard_csv", "get_clipboard_csv", # VLM locator "VLMNotAvailableError", "locate_by_description", "click_by_description", "verify_description", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 4b1b2903..92aae01f 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1597,6 +1597,31 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: "AC_get_clipboard_files", "Data", "Get Clipboard Files", description="Read the clipboard's file-drop list (CF_HDROP, Windows).", )) + specs.append(CommandSpec( + "AC_set_clipboard_rtf", "Data", "Set Clipboard RTF", + fields=(FieldSpec("text", FieldType.STRING, + placeholder="Styled paste text"),), + description="Put text on the clipboard as Rich Text Format (Windows).", + )) + specs.append(CommandSpec( + "AC_get_clipboard_rtf", "Data", "Get Clipboard RTF", + description="Read the clipboard's RTF document string (Windows).", + )) + specs.append(CommandSpec( + "AC_set_clipboard_csv", "Data", "Set Clipboard CSV/TSV", + fields=( + FieldSpec("rows", FieldType.STRING, + placeholder='[["a", "b"], ["1", "2"]]'), + FieldSpec("delimiter", FieldType.STRING, optional=True, default=","), + ), + description="Put a table on the clipboard as the Csv format (Windows).", + )) + specs.append(CommandSpec( + "AC_get_clipboard_csv", "Data", "Get Clipboard CSV/TSV", + fields=(FieldSpec("delimiter", FieldType.STRING, optional=True, + default=","),), + description="Read the clipboard's Csv content as rows (Windows).", + )) specs.append(CommandSpec( "AC_watchdog_add", "Flow", "Watchdog: Add Popup Rule", fields=( diff --git a/je_auto_control/utils/clipboard_rich_formats/__init__.py b/je_auto_control/utils/clipboard_rich_formats/__init__.py new file mode 100644 index 00000000..57d63070 --- /dev/null +++ b/je_auto_control/utils/clipboard_rich_formats/__init__.py @@ -0,0 +1,11 @@ +"""Rich clipboard formats — RTF and CSV/TSV codecs + Windows get / set.""" +from je_auto_control.utils.clipboard_rich_formats.clipboard_rich_formats import ( + build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, + rtf_to_text, set_clipboard_csv, set_clipboard_rtf, +) + +__all__ = [ + "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", + "set_clipboard_rtf", "get_clipboard_rtf", + "set_clipboard_csv", "get_clipboard_csv", +] diff --git a/je_auto_control/utils/clipboard_rich_formats/clipboard_rich_formats.py b/je_auto_control/utils/clipboard_rich_formats/clipboard_rich_formats.py new file mode 100644 index 00000000..8e277b24 --- /dev/null +++ b/je_auto_control/utils/clipboard_rich_formats/clipboard_rich_formats.py @@ -0,0 +1,266 @@ +"""Rich clipboard formats — RTF and CSV/TSV get / set. + +``rich_clipboard`` added ``CF_HTML`` for rich paste into Word / Outlook, but two +other cross-app formats were still missing: + +* **RTF** (``"Rich Text Format"``) — the format almost every rich editor accepts + for styled paste. Building / stripping RTF control words and ``\\uNNNN`` / + ``\\'XX`` escapes is the error-prone part; :func:`build_rtf` / :func:`rtf_to_text` + do it in pure Python with a fully unit-testable round-trip. +* **CSV / TSV** (the registered ``"Csv"`` format Excel reads) — :func:`rows_to_csv` + / :func:`csv_to_rows` are a thin, delimiter-parametrised wrapper over the stdlib + ``csv`` module so a table can be put on / read off the clipboard. + +The codecs are platform-independent and headless-testable; only the actual +clipboard I/O is Win32 (raising ``RuntimeError`` elsewhere, like the base +``clipboard`` module). The Win32 byte transfer is a single generic helper shared +by both formats. Imports no ``PySide6``. +""" +import csv +import io +import sys +from typing import List, Optional, Sequence + +_RTF_FORMAT_NAME = "Rich Text Format" +_CSV_FORMAT_NAME = "Csv" +_GMEM_MOVEABLE = 0x0002 +_RTF_PREAMBLE = "{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0\\fnil Calibri;}}\n" +_RTF_LITERAL_ESCAPE = {"\\": "\\\\", "{": "\\{", "}": "\\}", + "\n": "\\par\n", "\r": "", "\t": "\\tab "} +# RTF destination groups whose textual content is metadata, not document text. +_RTF_DESTINATIONS = frozenset({ + "fonttbl", "colortbl", "stylesheet", "info", "pict", "header", "footer", + "object", "themedata", "datastore", "operator", "generator", "rsidtbl", +}) + + +# --- RTF codec (pure) ------------------------------------------------------ + +def _escape_char(char: str) -> str: + if char in _RTF_LITERAL_ESCAPE: + return _RTF_LITERAL_ESCAPE[char] + if ord(char) > 127: + return f"\\u{ord(char)}?" + return char + + +def build_rtf(text: str) -> str: + """Wrap plain ``text`` in a minimal, valid RTF document. + + Backslash / brace are escaped, newlines become ``\\par`` and non-ASCII + characters become ``\\uNNNN?`` escapes, so the result is pure ASCII. + """ + if not isinstance(text, str): + raise TypeError("build_rtf expects a str") + return _RTF_PREAMBLE + "".join(_escape_char(ch) for ch in text) + "}" + + +def _apply_word(word: str, param: str, stack: List[bool], + result: List[str]) -> int: + """Apply one RTF control word; return how many following chars to skip.""" + if word in _RTF_DESTINATIONS: + stack[-1] = True + return 0 + if stack[-1]: + return 1 if word == "u" else 0 + if word in ("par", "line"): + result.append("\n") + elif word == "tab": + result.append("\t") + elif word == "u" and param: + result.append(chr(int(param) % 0x10000)) + return 1 # the trailing fallback char is consumed + return 0 + + +def _consume_word(text: str, i: int, stack: List[bool], + result: List[str]) -> int: + n = len(text) + j = i + 1 + while j < n and text[j].isalpha(): + j += 1 + k = j + 1 if j < n and text[j] == "-" else j + while k < n and text[k].isdigit(): + k += 1 + param = text[j:k] + if k < n and text[k] == " ": + k += 1 + return k + _apply_word(text[i + 1:j], param, stack, result) + + +def _consume_hex(text: str, i: int, stack: List[bool], + result: List[str]) -> int: + if not stack[-1]: + try: + byte = bytes([int(text[i + 2:i + 4], 16)]) + result.append(byte.decode("cp1252", "replace")) + except ValueError: + pass + return i + 4 + + +def _consume_control(text: str, i: int, stack: List[bool], + result: List[str]) -> int: + nxt = text[i + 1] if i + 1 < len(text) else "" + if nxt in "\\{}": + if not stack[-1]: + result.append(nxt) + return i + 2 + if nxt == "*": + stack[-1] = True + return i + 2 + if nxt == "'": + return _consume_hex(text, i, stack, result) + if nxt.isalpha(): + return _consume_word(text, i, stack, result) + return i + 2 # other control symbol (\~, \-, …) + + +def rtf_to_text(rtf: str) -> str: + """Strip an RTF document to its plain text (inverse of :func:`build_rtf`). + + Drops metadata groups (font / colour / style tables, etc.), converts + ``\\par`` / ``\\line`` to newlines and ``\\tab`` to a tab, and decodes + ``\\uNNNN`` / ``\\'XX`` character escapes. + """ + text = str(rtf) + result: List[str] = [] + stack: List[bool] = [False] + i, n = 0, len(text) + while i < n: + char = text[i] + if char == "{": + stack.append(stack[-1]) + i += 1 + elif char == "}": + if len(stack) > 1: + stack.pop() + i += 1 + elif char == "\\": + i = _consume_control(text, i, stack, result) + elif char in "\r\n": + i += 1 # raw line breaks in the source are insignificant + else: + if not stack[-1]: + result.append(char) + i += 1 + return "".join(result) + + +# --- CSV / TSV codec (pure) ------------------------------------------------ + +def rows_to_csv(rows: Sequence[Sequence[object]], *, delimiter: str = ",") -> str: + """Serialise rows of cells to CSV/TSV text (use ``delimiter="\\t"`` for TSV).""" + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter=delimiter, lineterminator="\r\n") + writer.writerows([[str(cell) for cell in row] for row in rows]) + return buffer.getvalue() + + +def csv_to_rows(text: str, *, delimiter: str = ",") -> List[List[str]]: + """Parse CSV/TSV text into a list of cell-string rows.""" + return [list(row) for row in csv.reader(io.StringIO(str(text)), + delimiter=delimiter)] + + +# --- Win32 clipboard I/O --------------------------------------------------- + +def _format_id(name: str) -> int: + import ctypes + return ctypes.windll.user32.RegisterClipboardFormatW(name) + + +def _win_set_format(format_id: int, payload: bytes, *, + empty_first: bool = True) -> None: + import ctypes + from ctypes import wintypes + user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32 + kernel32.GlobalAlloc.restype = wintypes.HGLOBAL + kernel32.GlobalLock.restype = ctypes.c_void_p + if not user32.OpenClipboard(None): + raise RuntimeError("OpenClipboard failed") + try: + if empty_first: + user32.EmptyClipboard() + handle = kernel32.GlobalAlloc(_GMEM_MOVEABLE, len(payload)) + if not handle: + raise RuntimeError("GlobalAlloc failed") + pointer = kernel32.GlobalLock(handle) + ctypes.memmove(pointer, payload, len(payload)) + kernel32.GlobalUnlock(handle) + if not user32.SetClipboardData(format_id, handle): + raise RuntimeError("SetClipboardData failed") + finally: + user32.CloseClipboard() + + +def _win_get_format(format_id: int) -> 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(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() + + +def _seed_plaintext(text: str) -> None: + from je_auto_control.utils.clipboard.clipboard import set_clipboard + set_clipboard(text) + + +def set_clipboard_rtf(text: str, *, plaintext: bool = True) -> None: + """Put ``text`` on the clipboard as Rich Text Format (Windows only). + + With ``plaintext`` (default) the raw text is also placed as ``CF_UNICODETEXT`` + so plain editors still paste something. Raises ``RuntimeError`` off Windows. + """ + if not isinstance(text, str): + raise TypeError("set_clipboard_rtf expects a str") + if not sys.platform.startswith("win"): + raise RuntimeError("set_clipboard_rtf is only supported on Windows") + payload = build_rtf(text).encode("ascii", "replace") + b"\x00" + if plaintext: + _seed_plaintext(text) + _win_set_format(_format_id(_RTF_FORMAT_NAME), payload, empty_first=not plaintext) + + +def get_clipboard_rtf() -> Optional[str]: + """Return the clipboard's RTF document string, or ``None`` (Windows only).""" + if not sys.platform.startswith("win"): + raise RuntimeError("get_clipboard_rtf is only supported on Windows") + blob = _win_get_format(_format_id(_RTF_FORMAT_NAME)) + return blob.decode("latin-1") if blob is not None else None + + +def set_clipboard_csv(rows: Sequence[Sequence[object]], *, delimiter: str = ",", + plaintext: bool = True) -> None: + """Put a table on the clipboard as the ``Csv`` format Excel reads (Windows).""" + if not sys.platform.startswith("win"): + raise RuntimeError("set_clipboard_csv is only supported on Windows") + text = rows_to_csv(rows, delimiter=delimiter) + payload = text.encode("utf-8") + b"\x00" + if plaintext: + _seed_plaintext(text) + _win_set_format(_format_id(_CSV_FORMAT_NAME), payload, empty_first=not plaintext) + + +def get_clipboard_csv(*, delimiter: str = ",") -> Optional[List[List[str]]]: + """Return the clipboard's ``Csv`` content as cell-string rows, or ``None``.""" + if not sys.platform.startswith("win"): + raise RuntimeError("get_clipboard_csv is only supported on Windows") + blob = _win_get_format(_format_id(_CSV_FORMAT_NAME)) + if blob is None: + return None + return csv_to_rows(blob.decode("utf-8", "replace"), delimiter=delimiter) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 4955e76c..f85d4b83 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -4185,6 +4185,37 @@ def _get_clipboard_files() -> Dict[str, Any]: return {"found": paths is not None, "paths": paths or []} +def _set_clipboard_rtf(text: str) -> Dict[str, Any]: + """Adapter: put text on the clipboard as Rich Text Format (Windows).""" + from je_auto_control.utils.clipboard_rich_formats import set_clipboard_rtf + set_clipboard_rtf(str(text)) + return {"set": True, "length": len(str(text))} + + +def _get_clipboard_rtf() -> Dict[str, Any]: + """Adapter: read the clipboard's RTF document string (Windows).""" + from je_auto_control.utils.clipboard_rich_formats import get_clipboard_rtf + rtf = get_clipboard_rtf() + return {"found": rtf is not None, "rtf": rtf} + + +def _set_clipboard_csv(rows: Any, delimiter: str = ",") -> Dict[str, Any]: + """Adapter: put a table on the clipboard as the Csv format (Windows).""" + import json + from je_auto_control.utils.clipboard_rich_formats import set_clipboard_csv + if isinstance(rows, str): + rows = json.loads(rows) + set_clipboard_csv(rows, delimiter=str(delimiter)) + return {"set": True, "rows": len(rows)} + + +def _get_clipboard_csv(delimiter: str = ",") -> Dict[str, Any]: + """Adapter: read the clipboard's Csv content as rows (Windows).""" + from je_auto_control.utils.clipboard_rich_formats import get_clipboard_csv + rows = get_clipboard_csv(delimiter=str(delimiter)) + return {"found": rows is not None, "rows": rows or []} + + def _image_histogram(source: Any = None, bins: Any = 32, space: str = "hsv", region: Any = None) -> Dict[str, Any]: """Adapter: per-channel colour histogram of an image / the screen.""" @@ -6398,6 +6429,10 @@ def __init__(self): "AC_get_clipboard_html": _get_clipboard_html, "AC_set_clipboard_files": _set_clipboard_files, "AC_get_clipboard_files": _get_clipboard_files, + "AC_set_clipboard_rtf": _set_clipboard_rtf, + "AC_get_clipboard_rtf": _get_clipboard_rtf, + "AC_set_clipboard_csv": _set_clipboard_csv, + "AC_get_clipboard_csv": _get_clipboard_csv, "AC_image_histogram": _image_histogram, "AC_histogram_changed": _histogram_changed, "AC_changed_regions": _changed_regions, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 868bd6a4..8d0188aa 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3252,6 +3252,46 @@ def clipboard_files_tools() -> List[MCPTool]: handler=h.get_clipboard_files, annotations=READ_ONLY, ), + MCPTool( + name="ac_set_clipboard_rtf", + description=("Put 'text' on the clipboard as Rich Text Format so it " + "pastes as styled text into Word / rich editors (Windows). " + "Also seeds plain text. Returns {set, length}."), + input_schema=schema({"text": {"type": "string"}}, required=["text"]), + handler=h.set_clipboard_rtf, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_get_clipboard_rtf", + description=("Read the clipboard's RTF document string (Windows). " + "Returns {found, rtf}."), + input_schema=schema({}, required=[]), + handler=h.get_clipboard_rtf, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_set_clipboard_csv", + description=("Put a table on the clipboard as the 'Csv' format Excel " + "reads (Windows). 'rows' is a list of row arrays; " + "'delimiter' defaults to ',' (use '\\t' for TSV). " + "Returns {set, rows}."), + input_schema=schema({ + "rows": {"type": "array", + "items": {"type": "array", "items": {"type": "string"}}}, + "delimiter": {"type": "string"}}, + required=["rows"]), + handler=h.set_clipboard_csv, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_get_clipboard_csv", + description=("Read the clipboard's 'Csv' content as rows of cell " + "strings (Windows). 'delimiter' defaults to ','. " + "Returns {found, rows}."), + input_schema=schema({"delimiter": {"type": "string"}}), + handler=h.get_clipboard_csv, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index a609c203..55045b29 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2464,6 +2464,26 @@ def get_clipboard_files(): return _get_clipboard_files() +def set_clipboard_rtf(text): + from je_auto_control.utils.executor.action_executor import _set_clipboard_rtf + return _set_clipboard_rtf(text) + + +def get_clipboard_rtf(): + from je_auto_control.utils.executor.action_executor import _get_clipboard_rtf + return _get_clipboard_rtf() + + +def set_clipboard_csv(rows, delimiter=","): + from je_auto_control.utils.executor.action_executor import _set_clipboard_csv + return _set_clipboard_csv(rows, delimiter) + + +def get_clipboard_csv(delimiter=","): + from je_auto_control.utils.executor.action_executor import _get_clipboard_csv + return _get_clipboard_csv(delimiter) + + def image_histogram(source=None, bins=32, space="hsv", region=None): from je_auto_control.utils.executor.action_executor import _image_histogram return _image_histogram(source, bins, space, region) diff --git a/test/unit_test/headless/test_clipboard_rich_formats_batch.py b/test/unit_test/headless/test_clipboard_rich_formats_batch.py new file mode 100644 index 00000000..785d8a01 --- /dev/null +++ b/test/unit_test/headless/test_clipboard_rich_formats_batch.py @@ -0,0 +1,70 @@ +"""Headless tests for RTF + CSV/TSV clipboard codecs (pure; Win32 set/get skipped).""" +import je_auto_control as ac +from je_auto_control.utils.clipboard_rich_formats import ( + build_rtf, csv_to_rows, rows_to_csv, rtf_to_text, +) + + +def test_rtf_round_trip_plain_and_breaks(): + for sample in ("Hello world", "Line1\nLine2\nLine3", "Tab\tsep", ""): + assert rtf_to_text(build_rtf(sample)) == sample + + +def test_rtf_round_trip_unicode_and_escapes(): + for sample in ("café crème", "中文字符", "Braces {x} and \\ backslash"): + assert rtf_to_text(build_rtf(sample)) == sample + + +def test_build_rtf_is_ascii_and_wrapped(): + rtf = build_rtf("café") + assert rtf.startswith("{\\rtf1") + assert rtf.endswith("}") + rtf.encode("ascii") # non-ASCII escaped to \uNNNN?, so this must not raise + assert "\\u233?" in rtf # é + + +def test_rtf_to_text_drops_metadata_and_decodes_hex(): + raw = (r"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}" + r"{\colortbl;\red0\green0\blue0;}Caf\'e9 here\par done}") + assert rtf_to_text(raw) == "Café here\ndone" + + +def test_build_rtf_type_error(): + import pytest + with pytest.raises(TypeError): + build_rtf(123) + + +def test_csv_round_trip_with_embedded_specials(): + rows = [["a", "b,c"], ["d", "e\nf"], ["1", "2"]] + assert csv_to_rows(rows_to_csv(rows)) == [["a", "b,c"], ["d", "e\nf"], + ["1", "2"]] + + +def test_tsv_uses_tab_delimiter(): + text = rows_to_csv([["x", "y"], ["1", "2"]], delimiter="\t") + assert text == "x\ty\r\n1\t2\r\n" + assert csv_to_rows(text, delimiter="\t") == [["x", "y"], ["1", "2"]] + + +# --- wiring (Win32 set/get not executed in CI) ----------------------------- + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_set_clipboard_rtf", "AC_get_clipboard_rtf", + "AC_set_clipboard_csv", "AC_get_clipboard_csv"} <= 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_rtf", "ac_get_clipboard_rtf", + "ac_set_clipboard_csv", "ac_get_clipboard_csv"} <= 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_rtf", "AC_get_clipboard_rtf", + "AC_set_clipboard_csv", "AC_get_clipboard_csv"} <= specs + + +def test_facade_exports(): + for name in ("build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", + "set_clipboard_rtf", "get_clipboard_rtf", + "set_clipboard_csv", "get_clipboard_csv"): + assert hasattr(ac, name) and name in ac.__all__