|
| 1 | +"""Inspect and classify the clipboard's available formats. |
| 2 | +
|
| 3 | +The clipboard usually holds the *same* content in several formats at once — a |
| 4 | +copy from Word offers ``CF_UNICODETEXT`` + ``HTML Format`` + ``Rich Text Format``, |
| 5 | +a file copy offers ``CF_HDROP``, a screenshot offers ``CF_DIB``. Knowing *which |
| 6 | +formats are present* (without consuming any of them) tells an automation what it |
| 7 | +can paste and lets it detect when the clipboard's shape changed. |
| 8 | +
|
| 9 | +``clipboard_formats`` enumerates the live clipboard (``EnumClipboardFormats``) and |
| 10 | +classifies each format into a friendly category (text / image / files / html / |
| 11 | +rtf / csv / audio / …). The classifier and the snapshot diff are pure functions — |
| 12 | +unit-testable on any platform — and only the live enumeration is Win32 (raising |
| 13 | +``RuntimeError`` elsewhere, like the base ``clipboard`` module). Imports no |
| 14 | +``PySide6``. |
| 15 | +""" |
| 16 | +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union |
| 17 | + |
| 18 | +# Standard CF_* clipboard format ids → category. |
| 19 | +_CF_CATEGORY = { |
| 20 | + 1: "text", 7: "text", 13: "text", # CF_TEXT / OEMTEXT / UNICODETEXT |
| 21 | + 2: "image", 8: "image", 17: "image", # CF_BITMAP / DIB / DIBV5 |
| 22 | + 3: "image", 6: "image", 14: "image", # METAFILEPICT / TIFF / ENHMETAFILE |
| 23 | + 9: "palette", # CF_PALETTE |
| 24 | + 11: "audio", 12: "audio", # CF_RIFF / CF_WAVE |
| 25 | + 15: "files", # CF_HDROP |
| 26 | + 16: "locale", # CF_LOCALE |
| 27 | +} |
| 28 | +# Registered (named) clipboard formats → category (matched case-insensitively). |
| 29 | +_NAME_CATEGORY = { |
| 30 | + "html format": "html", |
| 31 | + "rich text format": "rtf", |
| 32 | + "rich text format without objects": "rtf", |
| 33 | + "csv": "csv", |
| 34 | + "png": "image", "jfif": "image", "gif": "image", "image/png": "image", |
| 35 | + "filenamew": "files", "filename": "files", "filegroupdescriptorw": "files", |
| 36 | + "filegroupdescriptor": "files", "shell idlist array": "files", |
| 37 | +} |
| 38 | +_Format = Union[int, Dict[str, Any], Tuple[int, str]] |
| 39 | + |
| 40 | + |
| 41 | +def _coerce(item: _Format) -> Tuple[int, str]: |
| 42 | + """Normalise a format descriptor (int / ``{id,name}`` / ``(id,name)``).""" |
| 43 | + if isinstance(item, dict): |
| 44 | + return int(item.get("id", 0)), str(item.get("name") or "") |
| 45 | + if isinstance(item, (tuple, list)): |
| 46 | + return int(item[0]), str(item[1] if len(item) > 1 else "") |
| 47 | + return int(item), "" |
| 48 | + |
| 49 | + |
| 50 | +def classify_format(format_id: int, name: Optional[str] = None) -> str: |
| 51 | + """Return the friendly category for one clipboard format. |
| 52 | +
|
| 53 | + A registered ``name`` (e.g. ``"HTML Format"``) takes priority; otherwise the |
| 54 | + standard ``CF_*`` id is mapped. Anything unrecognised is ``"other"``. |
| 55 | + """ |
| 56 | + if name: |
| 57 | + category = _NAME_CATEGORY.get(name.strip().lower()) |
| 58 | + if category is not None: |
| 59 | + return category |
| 60 | + return _CF_CATEGORY.get(int(format_id), "other") |
| 61 | + |
| 62 | + |
| 63 | +def classify_formats(formats: Sequence[_Format]) -> Dict[str, Any]: |
| 64 | + """Classify a list of clipboard formats into a summary. |
| 65 | +
|
| 66 | + Returns ``{categories, formats:[{id,name,category}], has_text, has_image, |
| 67 | + has_files}``. |
| 68 | + """ |
| 69 | + items: List[Dict[str, Any]] = [] |
| 70 | + for entry in formats: |
| 71 | + format_id, name = _coerce(entry) |
| 72 | + items.append({"id": format_id, "name": name, |
| 73 | + "category": classify_format(format_id, name)}) |
| 74 | + categories = sorted({item["category"] for item in items}) |
| 75 | + return {"categories": categories, "formats": items, |
| 76 | + "has_text": "text" in categories, |
| 77 | + "has_image": "image" in categories, |
| 78 | + "has_files": "files" in categories} |
| 79 | + |
| 80 | + |
| 81 | +def diff_formats(before: Sequence[_Format], |
| 82 | + after: Sequence[_Format]) -> Dict[str, Any]: |
| 83 | + """Diff two clipboard-format snapshots into ``{added, removed, changed}``. |
| 84 | +
|
| 85 | + ``added`` / ``removed`` are classified format dicts; ``changed`` is True when |
| 86 | + either is non-empty — a pure monitor primitive over two enumerations. |
| 87 | + """ |
| 88 | + def keyed(formats: Sequence[_Format]) -> Dict[Tuple[int, str], Dict[str, Any]]: |
| 89 | + out: Dict[Tuple[int, str], Dict[str, Any]] = {} |
| 90 | + for entry in formats: |
| 91 | + format_id, name = _coerce(entry) |
| 92 | + out[(format_id, name)] = {"id": format_id, "name": name, |
| 93 | + "category": classify_format(format_id, name)} |
| 94 | + return out |
| 95 | + |
| 96 | + before_map, after_map = keyed(before), keyed(after) |
| 97 | + added = [after_map[k] for k in after_map if k not in before_map] |
| 98 | + removed = [before_map[k] for k in before_map if k not in after_map] |
| 99 | + return {"added": added, "removed": removed, |
| 100 | + "changed": bool(added or removed)} |
| 101 | + |
| 102 | + |
| 103 | +# --- Win32 live enumeration ------------------------------------------------ |
| 104 | + |
| 105 | +def _format_name(user32, format_id: int) -> str: |
| 106 | + import ctypes |
| 107 | + buffer = ctypes.create_unicode_buffer(256) |
| 108 | + if user32.GetClipboardFormatNameW(format_id, buffer, 256): |
| 109 | + return buffer.value |
| 110 | + return "" |
| 111 | + |
| 112 | + |
| 113 | +def list_clipboard_formats() -> List[Dict[str, Any]]: |
| 114 | + """Return the formats currently on the clipboard as ``[{id, name}]`` (Windows).""" |
| 115 | + import sys |
| 116 | + if not sys.platform.startswith("win"): |
| 117 | + raise RuntimeError("list_clipboard_formats is only supported on Windows") |
| 118 | + import ctypes |
| 119 | + user32 = ctypes.windll.user32 |
| 120 | + if not user32.OpenClipboard(None): |
| 121 | + raise RuntimeError("OpenClipboard failed") |
| 122 | + try: |
| 123 | + formats: List[Dict[str, Any]] = [] |
| 124 | + format_id = user32.EnumClipboardFormats(0) |
| 125 | + while format_id: |
| 126 | + formats.append({"id": int(format_id), |
| 127 | + "name": _format_name(user32, format_id)}) |
| 128 | + format_id = user32.EnumClipboardFormats(format_id) |
| 129 | + return formats |
| 130 | + finally: |
| 131 | + user32.CloseClipboard() |
| 132 | + |
| 133 | + |
| 134 | +def clipboard_formats() -> Dict[str, Any]: |
| 135 | + """Enumerate and classify the live clipboard's formats (Windows). |
| 136 | +
|
| 137 | + Returns the :func:`classify_formats` summary for whatever is on the |
| 138 | + clipboard right now, without consuming any format. |
| 139 | + """ |
| 140 | + return classify_formats(list_clipboard_formats()) |
0 commit comments