workpytools 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. workpytools/__init__.py +1 -0
  2. workpytools/cli.py +76 -0
  3. workpytools/common/__init__.py +0 -0
  4. workpytools/common/browser_preview.py +40 -0
  5. workpytools/common/clipboard.py +318 -0
  6. workpytools/common/clustering.py +85 -0
  7. workpytools/common/config.py +44 -0
  8. workpytools/common/embedding.py +101 -0
  9. workpytools/common/help_gen.py +542 -0
  10. workpytools/common/logging.py +12 -0
  11. workpytools/common/markdown_html.py +15 -0
  12. workpytools/common/outline_parse.py +81 -0
  13. workpytools/common/output.py +71 -0
  14. workpytools/common/powerpoint.py +43 -0
  15. workpytools/common/shape_cluster.py +75 -0
  16. workpytools/common/table_shapes.py +157 -0
  17. workpytools/common/tabular.py +248 -0
  18. workpytools/common/textfile.py +90 -0
  19. workpytools/common/walk.py +133 -0
  20. workpytools/data/__init__.py +0 -0
  21. workpytools/data/synonym.tsv +3 -0
  22. workpytools/data/user.dic +2 -0
  23. workpytools/processing/__init__.py +0 -0
  24. workpytools/processing/base.py +23 -0
  25. workpytools/processing/clipfmt.py +143 -0
  26. workpytools/processing/clipmd.py +237 -0
  27. workpytools/processing/clipview.py +194 -0
  28. workpytools/processing/cwc.py +427 -0
  29. workpytools/processing/denoise.py +81 -0
  30. workpytools/processing/help.py +47 -0
  31. workpytools/processing/ikko.py +205 -0
  32. workpytools/processing/kukiri.py +94 -0
  33. workpytools/processing/lsdir.py +218 -0
  34. workpytools/processing/mdtsv.py +177 -0
  35. workpytools/processing/mokuji.py +91 -0
  36. workpytools/processing/nagasa.py +94 -0
  37. workpytools/processing/outline.py +85 -0
  38. workpytools/processing/profiler.py +277 -0
  39. workpytools/processing/seiretsu.py +111 -0
  40. workpytools/processing/tbl.py +358 -0
  41. workpytools/processing/touka.py +52 -0
  42. workpytools/processing/vv.py +147 -0
  43. workpytools-0.1.4.dist-info/METADATA +458 -0
  44. workpytools-0.1.4.dist-info/RECORD +48 -0
  45. workpytools-0.1.4.dist-info/WHEEL +5 -0
  46. workpytools-0.1.4.dist-info/entry_points.txt +20 -0
  47. workpytools-0.1.4.dist-info/licenses/LICENSE +21 -0
  48. workpytools-0.1.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import unicodedata
4
+ from pathlib import Path
5
+
6
+
7
+ class TextFileError(RuntimeError):
8
+ """Raised when a text file can't be read with any supported encoding."""
9
+
10
+
11
+ def normalize_newlines(text: str) -> str:
12
+ """Normalize CRLF / CR line endings to LF."""
13
+ return text.replace("\r\n", "\n").replace("\r", "\n")
14
+
15
+
16
+ def to_crlf(text: str) -> str:
17
+ """Convert line endings to CRLF, the Windows clipboard convention.
18
+
19
+ Normalizes to LF first so that already-CRLF input doesn't become CR CRLF.
20
+ """
21
+ return normalize_newlines(text).replace("\n", "\r\n")
22
+
23
+
24
+ def read_text_with_fallback(path: Path, encoding: str | None = None) -> str:
25
+ """Read a text file as UTF-8 (BOM tolerated), falling back to CP932.
26
+
27
+ Japanese Windows files are commonly CP932, so a UTF-8-only read would fail
28
+ on files written with Notepad and similar tools. Passing `encoding`
29
+ forces that codec with no fallback.
30
+ """
31
+ raw = path.read_bytes()
32
+
33
+ if encoding is not None:
34
+ try:
35
+ return raw.decode(encoding)
36
+ except UnicodeDecodeError as exc:
37
+ raise TextFileError(
38
+ f"指定されたエンコーディング {encoding!r} でファイルを読み込めません: {path}"
39
+ ) from exc
40
+
41
+ try:
42
+ return raw.decode("utf-8-sig")
43
+ except UnicodeDecodeError:
44
+ try:
45
+ return raw.decode("cp932")
46
+ except UnicodeDecodeError as exc:
47
+ raise TextFileError(
48
+ f"UTF-8/CP932のいずれでもファイルを読み込めません: {path}"
49
+ ) from exc
50
+
51
+
52
+ def display_width(text: str) -> int:
53
+ """Terminal display width, counting East Asian wide/fullwidth chars as 2.
54
+
55
+ `len()` is not usable for column alignment in this project because
56
+ Japanese text is half the character count of its rendered width.
57
+ """
58
+ return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in text)
59
+
60
+
61
+ def pad_to_width(text: str, width: int) -> str:
62
+ """Left-align `text` padded with spaces to `width` display columns."""
63
+ padding = width - display_width(text)
64
+ return text + " " * max(0, padding)
65
+
66
+
67
+ def truncate_to_width(text: str, width: int, ellipsis: str = "...") -> str:
68
+ """Truncate `text` so it fits within `width` display columns.
69
+
70
+ When truncation happens, `ellipsis` is appended and counted toward the
71
+ limit, so the result never exceeds `width`.
72
+ """
73
+ if display_width(text) <= width:
74
+ return text
75
+
76
+ ellipsis_width = display_width(ellipsis)
77
+ budget = width - ellipsis_width
78
+ if budget <= 0:
79
+ return ellipsis[:width]
80
+
81
+ result: list[str] = []
82
+ used = 0
83
+ for char in text:
84
+ char_width = display_width(char)
85
+ if used + char_width > budget:
86
+ break
87
+ result.append(char)
88
+ used += char_width
89
+
90
+ return "".join(result) + ellipsis
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ _DEFAULT_EXCLUDE = frozenset({".git", ".svn", "node_modules", "__pycache__"})
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Entry:
15
+ type: str # "file" or "dir"
16
+ name: str
17
+ fullpath: str
18
+ parent: str
19
+ ext: str
20
+ size: int | None
21
+ mtime: str
22
+ depth: int
23
+ source: str
24
+
25
+
26
+ def _mtime_str(stat_result: os.stat_result) -> str:
27
+ return datetime.fromtimestamp(stat_result.st_mtime).strftime("%Y/%m/%d %H:%M")
28
+
29
+
30
+ def walk(
31
+ root: str,
32
+ source_label: str,
33
+ exclude: frozenset[str] = _DEFAULT_EXCLUDE,
34
+ include_temp: bool = False,
35
+ ) -> tuple[list[Entry], int]:
36
+ """Walk `root` with an explicit stack (not recursion): measured ~27%
37
+ faster than a recursive generator, and immune to RecursionError on very
38
+ deep trees (see doc/lsdir.md for the benchmark).
39
+
40
+ Uses os.scandir + DirEntry.stat() rather than os.walk + os.stat(), since
41
+ DirEntry.stat() reuses information already fetched by scandir() instead
42
+ of making another syscall per entry (measured ~18% faster).
43
+
44
+ Returns (entries, skipped_dir_count). Directories that raise on access
45
+ are logged as warnings and skipped rather than aborting the whole walk.
46
+ """
47
+ entries: list[Entry] = []
48
+ skipped = 0
49
+
50
+ # (path, depth) スタック。再帰ではなく明示スタックにすることで、
51
+ # 深い階層でも RecursionError にならず、実測でも速い。
52
+ stack: list[tuple[str, int]] = [(root, 0)]
53
+
54
+ while stack:
55
+ current, depth = stack.pop()
56
+
57
+ try:
58
+ with os.scandir(current) as scan_it:
59
+ dir_entries = list(scan_it)
60
+ except OSError as exc:
61
+ skipped += 1
62
+ logger.warning("フォルダにアクセスできないためスキップします: %s (%s)", current, exc)
63
+ continue
64
+
65
+ for dir_entry in dir_entries:
66
+ if dir_entry.name in exclude:
67
+ continue
68
+ if not include_temp and dir_entry.name.startswith("~$"):
69
+ continue
70
+
71
+ try:
72
+ is_dir = dir_entry.is_dir(follow_symlinks=False)
73
+ stat_result = dir_entry.stat(follow_symlinks=False)
74
+ except OSError as exc:
75
+ logger.warning("エントリを読めないためスキップします: %s (%s)", dir_entry.path, exc)
76
+ continue
77
+
78
+ new_depth = depth + 1
79
+ ext = "" if is_dir else os.path.splitext(dir_entry.name)[1]
80
+
81
+ entries.append(
82
+ Entry(
83
+ type="dir" if is_dir else "file",
84
+ name=dir_entry.name,
85
+ fullpath=dir_entry.path,
86
+ parent=current,
87
+ ext=ext,
88
+ size=None if is_dir else stat_result.st_size,
89
+ mtime=_mtime_str(stat_result),
90
+ depth=new_depth,
91
+ source=source_label,
92
+ )
93
+ )
94
+
95
+ if is_dir:
96
+ stack.append((dir_entry.path, new_depth))
97
+
98
+ return entries, skipped
99
+
100
+
101
+ def dedupe_by_fullpath(entries: list[Entry]) -> tuple[list[Entry], int]:
102
+ """Remove entries whose normalized full path was already seen (for
103
+ overlapping roots, e.g. `lsdir C:\\work C:\\work\\sub`). Windows paths
104
+ are case-insensitive, so normcase() is used for the comparison key.
105
+ """
106
+ seen: set[str] = set()
107
+ result = []
108
+ skipped = 0
109
+ for entry in entries:
110
+ key = os.path.normcase(os.path.abspath(entry.fullpath))
111
+ if key in seen:
112
+ skipped += 1
113
+ continue
114
+ seen.add(key)
115
+ result.append(entry)
116
+ return result, skipped
117
+
118
+
119
+ def compute_total_sizes(entries: list[Entry]) -> dict[str, int]:
120
+ """Total size in bytes of everything under each directory entry's path.
121
+
122
+ Requires a full walk to have already completed (can't be streamed),
123
+ which is why --total-size is opt-in rather than the default.
124
+ """
125
+ totals: dict[str, int] = {}
126
+ # 深い順(depthが大きい順)に処理すれば、子の合計を親に一度で足し込める
127
+ for entry in sorted(entries, key=lambda e: -e.depth):
128
+ if entry.type == "file" and entry.size is not None:
129
+ totals[entry.parent] = totals.get(entry.parent, 0) + entry.size
130
+ elif entry.type == "dir":
131
+ own_total = totals.get(entry.fullpath, 0)
132
+ totals[entry.parent] = totals.get(entry.parent, 0) + own_total
133
+ return totals
File without changes
@@ -0,0 +1,3 @@
1
+ # cwc既定の同義語辞書(TSV: 代表語\t異表記1\t異表記2\t...)
2
+ # 行頭が # の行はコメント。既定の区切り文字分割経路(--semantic)専用。
3
+ なし 特になし 特にない ない
@@ -0,0 +1,2 @@
1
+ # cwc既定のユーザー辞書(Janome simpledic形式: 表層形,品詞,読み)
2
+ # 固有名詞や社内用語をここに追記して育てる。
File without changes
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from abc import ABC, abstractmethod
5
+
6
+
7
+ class Processor(ABC):
8
+ """Base class for a single processing command.
9
+
10
+ Subclass this in a new module under `tools/processing/` and it will be
11
+ picked up automatically as a `tools <name>` subcommand.
12
+ """
13
+
14
+ name: str
15
+ help: str = ""
16
+
17
+ @abstractmethod
18
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
19
+ """Register this processor's CLI arguments on `parser`."""
20
+
21
+ @abstractmethod
22
+ def run(self, args: argparse.Namespace) -> int:
23
+ """Execute the processing. Return a process exit code."""
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import re
6
+
7
+ from workpytools.common.clipboard import copy_text_to_clipboard, get_clipboard_text
8
+ from workpytools.processing.base import Processor
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # 引用の入れ子(`>`の繰り返し)とインデントを保ったまま、行頭の箇条書き記号 */+ を捉える。
13
+ _BULLET_RE = re.compile(r"^((?:>\s*)*)([ \t]*)([*+])(\s+)(.*)$")
14
+
15
+
16
+ def _is_horizontal_rule(line: str) -> bool:
17
+ """`* * *` / `- - -` / `___` などのCommonMark水平線かどうか。"""
18
+ stripped = line.strip()
19
+ if not stripped:
20
+ return False
21
+ without_spaces = stripped.replace(" ", "")
22
+ chars = set(without_spaces)
23
+ if len(chars) != 1:
24
+ return False
25
+ ch = next(iter(chars))
26
+ if ch not in "*-_":
27
+ return False
28
+ return len(without_spaces) >= 3
29
+
30
+
31
+ def normalize_bullet_markers(text: str) -> tuple[str, bool]:
32
+ """Rewrite leading `*`/`+` bullet markers to `-` so mdformat treats a
33
+ hand-typed mixed-marker list as one list instead of splitting it into
34
+ alternating adjacent lists. Returns (rewritten_text, changed).
35
+
36
+ Code fences and indented code blocks are left untouched; horizontal
37
+ rules (`* * *`) and emphasis (`**bold**`, `*italic*`) are not mistaken
38
+ for bullets.
39
+ """
40
+ lines = text.split("\n")
41
+ out: list[str] = []
42
+ in_fence = False
43
+ changed = False
44
+ prev_line_is_list_item = False
45
+
46
+ for line in lines:
47
+ stripped_left = line.lstrip()
48
+
49
+ if stripped_left.startswith("```") or stripped_left.startswith("~~~"):
50
+ in_fence = not in_fence
51
+ out.append(line)
52
+ prev_line_is_list_item = False
53
+ continue
54
+ if in_fence:
55
+ out.append(line)
56
+ continue
57
+
58
+ if not line.strip():
59
+ out.append(line)
60
+ continue
61
+
62
+ leading_spaces = len(line) - len(line.lstrip(" "))
63
+ if leading_spaces >= 4 and not prev_line_is_list_item:
64
+ # インデントコードブロックの可能性が高いため触らない
65
+ out.append(line)
66
+ prev_line_is_list_item = False
67
+ continue
68
+
69
+ if _is_horizontal_rule(line):
70
+ out.append(line)
71
+ prev_line_is_list_item = False
72
+ continue
73
+
74
+ match = _BULLET_RE.match(line)
75
+ if match:
76
+ quote_prefix, indent, marker, spacing, rest = match.groups()
77
+ if marker != "-":
78
+ changed = True
79
+ out.append(f"{quote_prefix}{indent}-{spacing}{rest}")
80
+ prev_line_is_list_item = True
81
+ continue
82
+
83
+ out.append(line)
84
+ prev_line_is_list_item = False
85
+
86
+ return "\n".join(out), changed
87
+
88
+
89
+ def format_markdown(text: str) -> str:
90
+ import mdformat
91
+
92
+ result: str = mdformat.text(text, extensions={"tables"})
93
+ return result
94
+
95
+
96
+ class ClipfmtProcessor(Processor):
97
+ """Format the clipboard's Markdown text (mdformat) and write it back.
98
+
99
+ Always reads CF_UNICODETEXT (CF_HTML, if present, is ignored) and writes
100
+ only CF_UNICODETEXT back -- formatting doesn't change the document's
101
+ format, so there's nothing rich-text-specific to set.
102
+ """
103
+
104
+ name = "clipfmt"
105
+ help = "クリップボードのMarkdownを整形する"
106
+
107
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
108
+ parser.add_argument(
109
+ "--no-normalize-bullets",
110
+ action="store_true",
111
+ help="箇条書き記号(*/+)を-に統一する前処理を行わない",
112
+ )
113
+
114
+ def run(self, args: argparse.Namespace) -> int:
115
+ try:
116
+ source_text = get_clipboard_text()
117
+ except Exception as exc:
118
+ raise SystemExit("整形対象のテキストがありません") from exc
119
+
120
+ text_to_format = source_text
121
+ if not args.no_normalize_bullets:
122
+ text_to_format, bullets_changed = normalize_bullet_markers(source_text)
123
+ if bullets_changed:
124
+ logger.info("箇条書き記号を - に統一しました")
125
+
126
+ try:
127
+ formatted = format_markdown(text_to_format)
128
+ except Exception as exc:
129
+ raise SystemExit(
130
+ f"Markdownの整形に失敗しました(クリップボードは変更していません): {exc}"
131
+ ) from exc
132
+
133
+ if not formatted.strip():
134
+ raise SystemExit("整形結果が空です")
135
+
136
+ if formatted == source_text:
137
+ logger.info("整形の必要はありませんでした(既に整形済みです)")
138
+ else:
139
+ logger.info("整形により内容が変わりました")
140
+
141
+ copy_text_to_clipboard(formatted)
142
+ print("整形しました")
143
+ return 0
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ from workpytools.common.clipboard import (
7
+ copy_html_and_text_to_clipboard,
8
+ copy_text_to_clipboard,
9
+ get_clipboard_html_fragment,
10
+ get_clipboard_text,
11
+ has_clipboard_html,
12
+ has_clipboard_text,
13
+ )
14
+ from workpytools.common.markdown_html import markdown_to_html_fragment
15
+ from workpytools.processing.base import Processor
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _preprocess_table_html(soup: object) -> list[str]:
21
+ """Rewrite <table> elements for markdownify: promote first row to <thead> when
22
+ missing, pad colspan/rowspan cells with empty cells so column counts line up.
23
+ Returns warning messages describing what was rewritten or will be lost.
24
+ """
25
+ from bs4 import Tag
26
+
27
+ warnings: list[str] = []
28
+
29
+ for table in soup.find_all("table"): # type: ignore[attr-defined]
30
+ if not table.find("thead"):
31
+ rows = table.find_all("tr")
32
+ if rows:
33
+ first_row = rows[0]
34
+ for cell in first_row.find_all("td"):
35
+ cell.name = "th"
36
+ thead = soup.new_tag("thead") # type: ignore[attr-defined]
37
+ first_row.wrap(thead)
38
+
39
+ for row in table.find_all("tr"):
40
+ for cell in row.find_all(["td", "th"]):
41
+ colspan = cell.get("colspan")
42
+ if colspan and int(colspan) > 1:
43
+ n = int(colspan) - 1
44
+ warnings.append(
45
+ f"表のセル結合(colspan={colspan})を検出したため、空セルで列数を揃えました"
46
+ )
47
+ del cell["colspan"]
48
+ for _ in range(n):
49
+ empty: Tag = soup.new_tag(cell.name) # type: ignore[attr-defined]
50
+ cell.insert_after(empty)
51
+ if cell.get("rowspan"):
52
+ warnings.append(
53
+ f"表のセル結合(rowspan={cell.get('rowspan')})を検出しました"
54
+ "(行方向の結合は補完されません)"
55
+ )
56
+ del cell["rowspan"]
57
+
58
+ for cell in row.find_all(["td", "th"]):
59
+ if cell.find(["p", "ul", "ol", "br"]):
60
+ warnings.append("表のセル内に改行やリストがあり、情報が失われました")
61
+ if cell.find("img"):
62
+ warnings.append("表のセル内の画像は変換で失われました")
63
+
64
+ return warnings
65
+
66
+
67
+ def _collect_alignment(soup: object) -> dict[int, dict[int, str]]:
68
+ """Collect per-table, per-column text-align, keyed by table index then column index."""
69
+ alignments: dict[int, dict[int, str]] = {}
70
+ for table_idx, table in enumerate(soup.find_all("table")): # type: ignore[attr-defined]
71
+ header_row = table.find("tr")
72
+ if header_row is None:
73
+ continue
74
+ col_aligns: dict[int, str] = {}
75
+ for col_idx, cell in enumerate(header_row.find_all(["th", "td"])):
76
+ style = cell.get("style", "")
77
+ if "text-align:right" in style or "text-align: right" in style:
78
+ col_aligns[col_idx] = "right"
79
+ elif "text-align:center" in style or "text-align: center" in style:
80
+ col_aligns[col_idx] = "center"
81
+ elif "text-align:left" in style or "text-align: left" in style:
82
+ col_aligns[col_idx] = "left"
83
+ if col_aligns:
84
+ alignments[table_idx] = col_aligns
85
+ return alignments
86
+
87
+
88
+ def _apply_alignment_to_markdown(markdown_text: str, alignments: dict[int, dict[int, str]]) -> str:
89
+ """Rewrite Markdown table separator rows (`| --- |`) to reflect collected alignment."""
90
+ if not alignments:
91
+ return markdown_text
92
+
93
+ lines = markdown_text.split("\n")
94
+ table_idx = -1
95
+ result = []
96
+ i = 0
97
+ while i < len(lines):
98
+ line = lines[i]
99
+ is_separator = (
100
+ line.strip().startswith("|")
101
+ and set(line.replace("|", "").replace("-", "").replace(":", "").strip()) == set()
102
+ and "-" in line
103
+ )
104
+ if is_separator and i > 0 and lines[i - 1].strip().startswith("|"):
105
+ table_idx += 1
106
+ col_aligns = alignments.get(table_idx, {})
107
+ cols = [c.strip() for c in line.strip().strip("|").split("|")]
108
+ new_cols = []
109
+ for col_idx, _ in enumerate(cols):
110
+ align = col_aligns.get(col_idx)
111
+ if align == "right":
112
+ new_cols.append("---:")
113
+ elif align == "center":
114
+ new_cols.append(":---:")
115
+ elif align == "left":
116
+ new_cols.append(":---")
117
+ else:
118
+ new_cols.append("---")
119
+ result.append("| " + " | ".join(new_cols) + " |")
120
+ else:
121
+ result.append(line)
122
+ i += 1
123
+ return "\n".join(result)
124
+
125
+
126
+ def _collect_image_warnings(soup: object) -> list[str]:
127
+ warnings: list[str] = []
128
+ for img in soup.find_all("img"): # type: ignore[attr-defined]
129
+ src = img.get("src", "")
130
+ if src.startswith("file:///"):
131
+ if "msohtmlclip" in src.lower():
132
+ warnings.append(
133
+ "画像がWord由来の一時ファイルを参照しています"
134
+ f"(後でリンクが切れる可能性があります): {src}"
135
+ )
136
+ else:
137
+ warnings.append(
138
+ "画像がローカルの一時ファイルらしきパスを参照しています"
139
+ f"(後でリンクが切れる可能性があります): {src}"
140
+ )
141
+ elif src.startswith("data:"):
142
+ b64_part = src.split(",", 1)[1] if "," in src else ""
143
+ size_kb = len(b64_part) * 3 / 4 / 1024
144
+ warnings.append(f"データURI画像を検出しました(約{size_kb:.1f}KBの1行になります)")
145
+ return warnings
146
+
147
+
148
+ def html_to_markdown(html_fragment: str) -> str:
149
+ from bs4 import BeautifulSoup
150
+ from markdownify import MarkdownConverter
151
+
152
+ soup = BeautifulSoup(html_fragment, "html.parser")
153
+
154
+ for warning in _preprocess_table_html(soup):
155
+ logger.warning(warning)
156
+ for warning in _collect_image_warnings(soup):
157
+ logger.warning(warning)
158
+
159
+ alignments = _collect_alignment(soup)
160
+
161
+ markdown_text = MarkdownConverter().convert_soup(soup).strip()
162
+ markdown_text = _apply_alignment_to_markdown(markdown_text, alignments)
163
+ return markdown_text
164
+
165
+
166
+ class ClipmdProcessor(Processor):
167
+ """Convert clipboard contents between Markdown and rich text (HTML).
168
+
169
+ Direction is auto-detected from what's on the clipboard: CF_HTML present
170
+ means rich text was copied (convert HTML -> Markdown); CF_UNICODETEXT
171
+ only means plain text was copied (convert Markdown -> rich text).
172
+ """
173
+
174
+ name = "clipmd"
175
+ help = "クリップボードのMarkdownとリッチテキストを相互変換する"
176
+
177
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
178
+ group = parser.add_mutually_exclusive_group()
179
+ group.add_argument(
180
+ "--to-markdown", action="store_true", help="入力をHTMLとみなしMarkdownに変換する"
181
+ )
182
+ group.add_argument(
183
+ "--to-rich", action="store_true", help="入力をMarkdownとみなしリッチテキストに変換する"
184
+ )
185
+
186
+ def run(self, args: argparse.Namespace) -> int:
187
+ direction, use_html_source = self._resolve_direction(args)
188
+
189
+ if direction == "to_markdown":
190
+ fragment = get_clipboard_html_fragment() if use_html_source else get_clipboard_text()
191
+ markdown_text = html_to_markdown(fragment)
192
+ if not markdown_text.strip():
193
+ raise SystemExit("変換結果が空です")
194
+ copy_text_to_clipboard(markdown_text)
195
+ print("HTML → Markdown に変換しました")
196
+ else:
197
+ source_text = get_clipboard_text()
198
+ html_fragment = markdown_to_html_fragment(source_text)
199
+ if not html_fragment.strip():
200
+ raise SystemExit("変換結果が空です")
201
+ copy_html_and_text_to_clipboard(html_fragment, source_text)
202
+ print("Markdown → リッチテキスト に変換しました")
203
+
204
+ return 0
205
+
206
+ def _resolve_direction(self, args: argparse.Namespace) -> tuple[str, bool]:
207
+ """Returns (direction, use_html_source_for_markdown_conversion)."""
208
+ has_html = has_clipboard_html()
209
+ has_text = has_clipboard_text()
210
+
211
+ if args.to_markdown:
212
+ if not has_html and not has_text:
213
+ raise SystemExit("変換できるテキストがありません")
214
+ if not has_html:
215
+ logger.info(
216
+ "--to-markdown指定: CF_HTMLが無いためCF_UNICODETEXTをHTMLソースとして解釈します"
217
+ )
218
+ return "to_markdown", False
219
+ return "to_markdown", True
220
+
221
+ if args.to_rich:
222
+ if not has_text:
223
+ raise SystemExit("変換できるテキストがありません")
224
+ if has_html:
225
+ logger.info(
226
+ "--to-rich指定: CF_HTMLを無視しCF_UNICODETEXTをMarkdownとして解釈します"
227
+ )
228
+ return "to_rich", False
229
+
230
+ if has_html:
231
+ logger.info("CF_HTMLを検出したため HTML → Markdown に変換します")
232
+ return "to_markdown", True
233
+ if has_text:
234
+ logger.info("CF_UNICODETEXTのみのため Markdown → リッチテキスト に変換します")
235
+ return "to_rich", False
236
+
237
+ raise SystemExit("変換できるテキストがありません")