Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
49 changes: 49 additions & 0 deletions docs/source/Eng/doc/new_features/v186_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
41 changes: 41 additions & 0 deletions docs/source/Zh/doc/new_features/v186_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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** 分類下)形式提供。
7 changes: 7 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/utils/clipboard_formats/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
140 changes: 140 additions & 0 deletions je_auto_control/utils/clipboard_formats/clipboard_formats.py
Original file line number Diff line number Diff line change
@@ -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())
29 changes: 29 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
]


Expand Down
Loading
Loading