diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 47ad558c..e9e0adad 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-24) — Clipboard Format Inspection (classify / diff available formats) + +See which formats are on the clipboard, and detect when its shape changes. Full reference: [`docs/source/Eng/doc/new_features/v186_features_doc.rst`](docs/source/Eng/doc/new_features/v186_features_doc.rst). + +- **`classify_format` / `classify_formats` / `diff_formats` / `list_clipboard_formats` / `clipboard_formats`** (`AC_clipboard_formats`, `AC_classify_formats`, `AC_diff_formats`): the clipboard usually holds the same content in several formats at once (a Word copy = text + HTML + RTF; a file copy = CF_HDROP; a screenshot = CF_DIB). This enumerates the live clipboard (`EnumClipboardFormats`) without consuming anything and classifies each format into a friendly category (text/image/files/html/rtf/csv/audio/…); `diff_formats` is a pure monitor primitive returning `{added, removed, changed}` between two snapshots. The classifier and diff are pure (registered names take priority over dynamic ids); only the live enumeration is Win32. No `PySide6`. + ## 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). diff --git a/docs/source/Eng/doc/new_features/v186_features_doc.rst b/docs/source/Eng/doc/new_features/v186_features_doc.rst new file mode 100644 index 00000000..7365ce07 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v186_features_doc.rst @@ -0,0 +1,49 @@ +Clipboard Format Inspection (classify / diff available formats) +=============================================================== + +The clipboard usually holds the *same* content in several formats at once — a +copy from Word offers ``CF_UNICODETEXT`` + ``HTML Format`` + ``Rich Text Format``, +a file copy offers ``CF_HDROP``, a screenshot offers ``CF_DIB``. Knowing *which +formats are present* (without consuming any of them) tells an automation what it +can paste, and comparing two snapshots detects when the clipboard's shape +changed. ``clipboard_formats`` adds: + +* :func:`classify_format` / :func:`classify_formats` — map standard ``CF_*`` ids + and registered format names to friendly categories (text / image / files / + html / rtf / csv / audio / …), +* :func:`diff_formats` — a pure monitor primitive: ``{added, removed, changed}`` + between two snapshots, +* :func:`list_clipboard_formats` / :func:`clipboard_formats` — enumerate the live + clipboard (``EnumClipboardFormats``) and classify it. + +The classifier and diff are pure functions (unit-testable on any platform); only +the live enumeration is Win32 (raising ``RuntimeError`` elsewhere). Imports no +``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import (classify_formats, diff_formats, + clipboard_formats) + + classify_formats([13, {"id": 49383, "name": "HTML Format"}]) + # {"categories": ["html", "text"], "has_text": True, "has_image": False, ...} + + diff_formats([13, 1], [13, 15]) # {"added": [files], "removed": [text], ...} + + clipboard_formats() # live clipboard summary (Windows) + +A descriptor is an id (``13``), an ``{"id": ..., "name": ...}`` dict, or an +``(id, name)`` tuple. A registered ``name`` takes priority over the id, since +registered formats have dynamic ids (``>= 0xC000``). Unrecognised formats are +``"other"``. + +Executor commands +----------------- + +``AC_clipboard_formats`` (live, Windows), ``AC_classify_formats`` (``formats``) +and ``AC_diff_formats`` (``before`` / ``after``) — the latter two are pure and +run anywhere. They are exposed as read-only ``ac_*`` MCP tools and as Script +Builder commands under **Data**. diff --git a/docs/source/Zh/doc/new_features/v186_features_doc.rst b/docs/source/Zh/doc/new_features/v186_features_doc.rst new file mode 100644 index 00000000..7cff1500 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v186_features_doc.rst @@ -0,0 +1,41 @@ +剪貼簿格式檢視(分類 / 比較可用格式) +==================================== + +剪貼簿通常同時以多種格式保存*相同*內容——從 Word 複製會提供 ``CF_UNICODETEXT`` + +``HTML Format`` + ``Rich Text Format``,複製檔案會提供 ``CF_HDROP``,截圖會提供 ``CF_DIB``。 +知道*目前有哪些格式*(且不消耗任何一個)能讓自動化判斷可以貼上什麼,比較兩份快照則能偵測 +剪貼簿的形態何時改變。``clipboard_formats`` 加入: + +* :func:`classify_format` / :func:`classify_formats` ——把標準 ``CF_*`` id 與已註冊格式名稱 + 對應到友善類別(text / image / files / html / rtf / csv / audio……), +* :func:`diff_formats` ——純粹的監看原語:兩份快照之間的 ``{added, removed, changed}``, +* :func:`list_clipboard_formats` / :func:`clipboard_formats` ——列舉存活的剪貼簿 + (``EnumClipboardFormats``)並加以分類。 + +分類器與比較器為純函式(可在任何平台單元測試);只有存活列舉為 Win32(其他平台拋出 +``RuntimeError``)。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import (classify_formats, diff_formats, + clipboard_formats) + + classify_formats([13, {"id": 49383, "name": "HTML Format"}]) + # {"categories": ["html", "text"], "has_text": True, "has_image": False, ...} + + diff_formats([13, 1], [13, 15]) # {"added": [files], "removed": [text], ...} + + clipboard_formats() # 存活剪貼簿摘要(Windows) + +描述子可為 id(``13``)、``{"id": ..., "name": ...}`` 字典,或 ``(id, name)`` 元組。已註冊的 +``name`` 優先於 id,因為已註冊格式的 id 是動態的(``>= 0xC000``)。未辨識的格式為 ``"other"``。 + +執行器指令 +---------- + +``AC_clipboard_formats``(存活,Windows)、``AC_classify_formats``(``formats``)與 +``AC_diff_formats``(``before`` / ``after``)——後兩者為純函式,可在任何平台執行。皆以唯讀 +``ac_*`` MCP 工具及 Script Builder 指令(位於 **Data** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index edb09043..e9c9e926 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -71,6 +71,11 @@ build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, rtf_to_text, set_clipboard_csv, set_clipboard_rtf, ) +# Clipboard format inspection (classify / diff available formats) +from je_auto_control.utils.clipboard_formats import ( + classify_format, classify_formats, clipboard_formats, diff_formats, + list_clipboard_formats, +) # VLM element locator (headless) from je_auto_control.utils.vision import ( VLMNotAvailableError, click_by_description, locate_by_description, @@ -1642,6 +1647,8 @@ def start_autocontrol_gui(*args, **kwargs): "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", + "classify_format", "classify_formats", "diff_formats", + "list_clipboard_formats", "clipboard_formats", # 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 92aae01f..dc6d60eb 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1622,6 +1622,24 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: default=","),), description="Read the clipboard's Csv content as rows (Windows).", )) + specs.append(CommandSpec( + "AC_clipboard_formats", "Data", "List Clipboard Formats", + description="Enumerate + classify the clipboard's formats (Windows).", + )) + specs.append(CommandSpec( + "AC_classify_formats", "Data", "Classify Clipboard Formats", + fields=(FieldSpec("formats", FieldType.STRING, + placeholder='[1, 13, {"id": 49161, "name": "Csv"}]'),), + description="Classify a provided list of clipboard formats (pure).", + )) + specs.append(CommandSpec( + "AC_diff_formats", "Data", "Diff Clipboard Formats", + fields=( + FieldSpec("before", FieldType.STRING, placeholder="[1, 13]"), + FieldSpec("after", FieldType.STRING, placeholder="[1, 13, 15]"), + ), + description="Diff two clipboard-format snapshots (pure).", + )) specs.append(CommandSpec( "AC_watchdog_add", "Flow", "Watchdog: Add Popup Rule", fields=( diff --git a/je_auto_control/utils/clipboard_formats/__init__.py b/je_auto_control/utils/clipboard_formats/__init__.py new file mode 100644 index 00000000..ac7520be --- /dev/null +++ b/je_auto_control/utils/clipboard_formats/__init__.py @@ -0,0 +1,10 @@ +"""Inspect and classify the clipboard's available formats (pure classify/diff + Win32 enum).""" +from je_auto_control.utils.clipboard_formats.clipboard_formats import ( + classify_format, classify_formats, clipboard_formats, diff_formats, + list_clipboard_formats, +) + +__all__ = [ + "classify_format", "classify_formats", "diff_formats", + "list_clipboard_formats", "clipboard_formats", +] diff --git a/je_auto_control/utils/clipboard_formats/clipboard_formats.py b/je_auto_control/utils/clipboard_formats/clipboard_formats.py new file mode 100644 index 00000000..0527b7e2 --- /dev/null +++ b/je_auto_control/utils/clipboard_formats/clipboard_formats.py @@ -0,0 +1,140 @@ +"""Inspect and classify the clipboard's available formats. + +The clipboard usually holds the *same* content in several formats at once — a +copy from Word offers ``CF_UNICODETEXT`` + ``HTML Format`` + ``Rich Text Format``, +a file copy offers ``CF_HDROP``, a screenshot offers ``CF_DIB``. Knowing *which +formats are present* (without consuming any of them) tells an automation what it +can paste and lets it detect when the clipboard's shape changed. + +``clipboard_formats`` enumerates the live clipboard (``EnumClipboardFormats``) and +classifies each format into a friendly category (text / image / files / html / +rtf / csv / audio / …). The classifier and the snapshot diff are pure functions — +unit-testable on any platform — and only the live enumeration is Win32 (raising +``RuntimeError`` elsewhere, like the base ``clipboard`` module). Imports no +``PySide6``. +""" +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +# Standard CF_* clipboard format ids → category. +_CF_CATEGORY = { + 1: "text", 7: "text", 13: "text", # CF_TEXT / OEMTEXT / UNICODETEXT + 2: "image", 8: "image", 17: "image", # CF_BITMAP / DIB / DIBV5 + 3: "image", 6: "image", 14: "image", # METAFILEPICT / TIFF / ENHMETAFILE + 9: "palette", # CF_PALETTE + 11: "audio", 12: "audio", # CF_RIFF / CF_WAVE + 15: "files", # CF_HDROP + 16: "locale", # CF_LOCALE +} +# Registered (named) clipboard formats → category (matched case-insensitively). +_NAME_CATEGORY = { + "html format": "html", + "rich text format": "rtf", + "rich text format without objects": "rtf", + "csv": "csv", + "png": "image", "jfif": "image", "gif": "image", "image/png": "image", + "filenamew": "files", "filename": "files", "filegroupdescriptorw": "files", + "filegroupdescriptor": "files", "shell idlist array": "files", +} +_Format = Union[int, Dict[str, Any], Tuple[int, str]] + + +def _coerce(item: _Format) -> Tuple[int, str]: + """Normalise a format descriptor (int / ``{id,name}`` / ``(id,name)``).""" + if isinstance(item, dict): + return int(item.get("id", 0)), str(item.get("name") or "") + if isinstance(item, (tuple, list)): + return int(item[0]), str(item[1] if len(item) > 1 else "") + return int(item), "" + + +def classify_format(format_id: int, name: Optional[str] = None) -> str: + """Return the friendly category for one clipboard format. + + A registered ``name`` (e.g. ``"HTML Format"``) takes priority; otherwise the + standard ``CF_*`` id is mapped. Anything unrecognised is ``"other"``. + """ + if name: + category = _NAME_CATEGORY.get(name.strip().lower()) + if category is not None: + return category + return _CF_CATEGORY.get(int(format_id), "other") + + +def classify_formats(formats: Sequence[_Format]) -> Dict[str, Any]: + """Classify a list of clipboard formats into a summary. + + Returns ``{categories, formats:[{id,name,category}], has_text, has_image, + has_files}``. + """ + items: List[Dict[str, Any]] = [] + for entry in formats: + format_id, name = _coerce(entry) + items.append({"id": format_id, "name": name, + "category": classify_format(format_id, name)}) + categories = sorted({item["category"] for item in items}) + return {"categories": categories, "formats": items, + "has_text": "text" in categories, + "has_image": "image" in categories, + "has_files": "files" in categories} + + +def diff_formats(before: Sequence[_Format], + after: Sequence[_Format]) -> Dict[str, Any]: + """Diff two clipboard-format snapshots into ``{added, removed, changed}``. + + ``added`` / ``removed`` are classified format dicts; ``changed`` is True when + either is non-empty — a pure monitor primitive over two enumerations. + """ + def keyed(formats: Sequence[_Format]) -> Dict[Tuple[int, str], Dict[str, Any]]: + out: Dict[Tuple[int, str], Dict[str, Any]] = {} + for entry in formats: + format_id, name = _coerce(entry) + out[(format_id, name)] = {"id": format_id, "name": name, + "category": classify_format(format_id, name)} + return out + + before_map, after_map = keyed(before), keyed(after) + added = [after_map[k] for k in after_map if k not in before_map] + removed = [before_map[k] for k in before_map if k not in after_map] + return {"added": added, "removed": removed, + "changed": bool(added or removed)} + + +# --- Win32 live enumeration ------------------------------------------------ + +def _format_name(user32, format_id: int) -> str: + import ctypes + buffer = ctypes.create_unicode_buffer(256) + if user32.GetClipboardFormatNameW(format_id, buffer, 256): + return buffer.value + return "" + + +def list_clipboard_formats() -> List[Dict[str, Any]]: + """Return the formats currently on the clipboard as ``[{id, name}]`` (Windows).""" + import sys + if not sys.platform.startswith("win"): + raise RuntimeError("list_clipboard_formats is only supported on Windows") + import ctypes + user32 = ctypes.windll.user32 + if not user32.OpenClipboard(None): + raise RuntimeError("OpenClipboard failed") + try: + formats: List[Dict[str, Any]] = [] + format_id = user32.EnumClipboardFormats(0) + while format_id: + formats.append({"id": int(format_id), + "name": _format_name(user32, format_id)}) + format_id = user32.EnumClipboardFormats(format_id) + return formats + finally: + user32.CloseClipboard() + + +def clipboard_formats() -> Dict[str, Any]: + """Enumerate and classify the live clipboard's formats (Windows). + + Returns the :func:`classify_formats` summary for whatever is on the + clipboard right now, without consuming any format. + """ + return classify_formats(list_clipboard_formats()) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index f85d4b83..150fb5f7 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -4216,6 +4216,32 @@ def _get_clipboard_csv(delimiter: str = ",") -> Dict[str, Any]: return {"found": rows is not None, "rows": rows or []} +def _clipboard_formats() -> Dict[str, Any]: + """Adapter: enumerate and classify the live clipboard's formats (Windows).""" + from je_auto_control.utils.clipboard_formats import clipboard_formats + return clipboard_formats() + + +def _classify_formats(formats: Any) -> Dict[str, Any]: + """Adapter: classify a provided list of clipboard formats (pure).""" + import json + from je_auto_control.utils.clipboard_formats import classify_formats + if isinstance(formats, str): + formats = json.loads(formats) + return classify_formats(formats) + + +def _diff_formats(before: Any, after: Any) -> Dict[str, Any]: + """Adapter: diff two clipboard-format snapshots (pure).""" + import json + from je_auto_control.utils.clipboard_formats import diff_formats + if isinstance(before, str): + before = json.loads(before) + if isinstance(after, str): + after = json.loads(after) + return diff_formats(before, after) + + 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.""" @@ -6433,6 +6459,9 @@ def __init__(self): "AC_get_clipboard_rtf": _get_clipboard_rtf, "AC_set_clipboard_csv": _set_clipboard_csv, "AC_get_clipboard_csv": _get_clipboard_csv, + "AC_clipboard_formats": _clipboard_formats, + "AC_classify_formats": _classify_formats, + "AC_diff_formats": _diff_formats, "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 8d0188aa..f9899647 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3292,6 +3292,37 @@ def clipboard_files_tools() -> List[MCPTool]: handler=h.get_clipboard_csv, annotations=READ_ONLY, ), + MCPTool( + name="ac_clipboard_formats", + description=("Enumerate and classify the formats currently on the " + "clipboard without consuming them (Windows). Returns " + "{categories, formats:[{id,name,category}], has_text, " + "has_image, has_files}."), + input_schema=schema({}, required=[]), + handler=h.clipboard_formats, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_classify_formats", + description=("Classify a provided list of clipboard formats (pure). " + "'formats' is a list of ids or {id,name}. Returns the " + "same summary as ac_clipboard_formats."), + input_schema=schema({"formats": {"type": "array"}}, + required=["formats"]), + handler=h.classify_formats, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_diff_formats", + description=("Diff two clipboard-format snapshots (pure): " + "{added, removed, changed}. 'before'/'after' are lists " + "of ids or {id,name}."), + input_schema=schema({"before": {"type": "array"}, + "after": {"type": "array"}}, + required=["before", "after"]), + handler=h.diff_formats, + 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 55045b29..f45dcdc5 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2484,6 +2484,21 @@ def get_clipboard_csv(delimiter=","): return _get_clipboard_csv(delimiter) +def clipboard_formats(): + from je_auto_control.utils.executor.action_executor import _clipboard_formats + return _clipboard_formats() + + +def classify_formats(formats): + from je_auto_control.utils.executor.action_executor import _classify_formats + return _classify_formats(formats) + + +def diff_formats(before, after): + from je_auto_control.utils.executor.action_executor import _diff_formats + return _diff_formats(before, after) + + 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_formats_batch.py b/test/unit_test/headless/test_clipboard_formats_batch.py new file mode 100644 index 00000000..aa54c8a0 --- /dev/null +++ b/test/unit_test/headless/test_clipboard_formats_batch.py @@ -0,0 +1,80 @@ +"""Headless tests for clipboard-format classification + diff (pure; Win32 enum skipped).""" +import je_auto_control as ac +from je_auto_control.utils.clipboard_formats import ( + classify_format, classify_formats, diff_formats, +) + + +def test_classify_standard_ids(): + assert classify_format(13) == "text" # CF_UNICODETEXT + assert classify_format(1) == "text" # CF_TEXT + assert classify_format(8) == "image" # CF_DIB + assert classify_format(15) == "files" # CF_HDROP + assert classify_format(99999) == "other" + + +def test_classify_named_takes_priority(): + # registered ids are dynamic (>= 0xC000); the name decides the category + assert classify_format(49161, "Csv") == "csv" + assert classify_format(49383, "HTML Format") == "html" + assert classify_format(50000, "Rich Text Format") == "rtf" + assert classify_format(49500, "PNG") == "image" + + +def test_classify_formats_summary(): + summary = classify_formats([13, {"id": 15, "name": ""}, + {"id": 49383, "name": "HTML Format"}]) + assert summary["categories"] == ["files", "html", "text"] + assert summary["has_text"] is True + assert summary["has_files"] is True + assert summary["has_image"] is False + assert {"id": 15, "name": "", "category": "files"} in summary["formats"] + + +def test_diff_formats_added_removed(): + before = [13, 1] + after = [13, 15, {"id": 49383, "name": "HTML Format"}] + diff = diff_formats(before, after) + assert diff["changed"] is True + added_cats = {f["category"] for f in diff["added"]} + removed_cats = {f["category"] for f in diff["removed"]} + assert added_cats == {"files", "html"} + assert removed_cats == {"text"} # CF_TEXT(1) dropped; CF_UNICODETEXT kept + + +def test_diff_formats_no_change(): + diff = diff_formats([13, 15], [15, 13]) + assert diff == {"added": [], "removed": [], "changed": False} + + +def test_tuple_descriptor_accepted(): + assert classify_formats([(49161, "Csv")])["categories"] == ["csv"] + + +# --- wiring (Win32 enumeration not executed in CI) ------------------------- + +def test_executor_pure_paths(): + from je_auto_control.utils.executor.action_executor import ( + _classify_formats, _diff_formats) + assert _classify_formats("[13, 15]")["has_files"] is True + assert _diff_formats("[13]", "[13, 15]")["changed"] is True + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_clipboard_formats", "AC_classify_formats", + "AC_diff_formats"} <= 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_clipboard_formats", "ac_classify_formats", + "ac_diff_formats"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_clipboard_formats", "AC_classify_formats", + "AC_diff_formats"} <= specs + + +def test_facade_exports(): + for name in ("classify_format", "classify_formats", "diff_formats", + "list_clipboard_formats", "clipboard_formats"): + assert hasattr(ac, name) and name in ac.__all__