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,358 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ from workpytools.common.powerpoint import (
7
+ NoActivePresentationError,
8
+ PowerPointNotRunningError,
9
+ get_active_presentation,
10
+ get_running_powerpoint,
11
+ )
12
+ from workpytools.common.table_shapes import (
13
+ GAP_RATIO,
14
+ GRID_TOLERANCE,
15
+ DuplicateGridPositionError,
16
+ GridShape,
17
+ compute_spaced_positions,
18
+ estimate_grid,
19
+ )
20
+ from workpytools.processing.base import Processor
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _SELECTION_SHAPES = 2 # ppSelectionShapes
25
+ _MSO_SHAPE_RECTANGLE = 1
26
+ _BORDER_SIDES = (1, 2, 3, 4) # ppBorderTop/Left/Bottom/Right
27
+
28
+
29
+ class TblProcessor(Processor):
30
+ """Convert between a PowerPoint table and a matrix of independent
31
+ shapes, and split a single multi-line text shape into one shape per
32
+ line. The direction is inferred from the current selection:
33
+
34
+ - selection contains a table -> decompose (table -> rects)
35
+ - 2+ shapes selected, no table -> compose (rects -> table)
36
+ - exactly 1 shape, multi-line text -> line-split
37
+ - anything else (incl. nothing selected) -> error or no-op, see run()
38
+ """
39
+
40
+ name = "tbl"
41
+ help = "PowerPointの表とシェイプ群を相互変換する(表分解/表合成/行分割)"
42
+
43
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
44
+ pass
45
+
46
+ def run(self, args: argparse.Namespace) -> int:
47
+ try:
48
+ app = get_running_powerpoint()
49
+ except PowerPointNotRunningError as exc:
50
+ raise SystemExit(
51
+ "PowerPointでプレゼンテーションを開いた状態で実行してください"
52
+ ) from exc
53
+
54
+ try:
55
+ get_active_presentation(app)
56
+ except NoActivePresentationError as exc:
57
+ raise SystemExit(
58
+ "PowerPointでプレゼンテーションを開いた状態で実行してください"
59
+ ) from exc
60
+
61
+ try:
62
+ selection = app.ActiveWindow.Selection
63
+ except Exception as exc:
64
+ raise SystemExit(f"選択状態の取得中にエラーが発生しました: {exc}") from exc
65
+
66
+ if selection.Type != _SELECTION_SHAPES:
67
+ raise SystemExit("対象を選択してから実行してください")
68
+
69
+ shape_range = selection.ShapeRange
70
+ selected = [shape_range.Item(i) for i in range(1, shape_range.Count + 1)]
71
+
72
+ tables = [s for s in selected if getattr(s, "HasTable", False)]
73
+
74
+ if tables:
75
+ return self._run_decompose(app, tables)
76
+
77
+ if len(selected) >= 2:
78
+ return self._run_compose(app, selected)
79
+
80
+ if len(selected) == 1:
81
+ return self._run_line_split(app, selected[0])
82
+
83
+ raise SystemExit("対象を選択してから実行してください")
84
+
85
+ # --- 分解: 表 -> 四角形群 ---
86
+
87
+ def _run_decompose(self, app: object, tables: list[object]) -> int:
88
+ try:
89
+ app.StartNewUndoEntry() # type: ignore[attr-defined]
90
+ total_shapes = 0
91
+ for table_shape in tables:
92
+ total_shapes += self._decompose_one_table(table_shape)
93
+ except Exception as exc:
94
+ raise SystemExit(
95
+ f"PowerPointの操作中にエラーが発生しました(Ctrl+Zで開始前の状態に戻せます): {exc}"
96
+ ) from exc
97
+
98
+ logger.info("分解した表の数=%d 作成した四角形数=%d", len(tables), total_shapes)
99
+ print(f"{len(tables)}個の表を分解しました(合計{total_shapes}個の四角形に)")
100
+ return 0
101
+
102
+ def _decompose_one_table(self, table_shape: object) -> int:
103
+ table = table_shape.Table # type: ignore[attr-defined]
104
+ n_rows = table.Rows.Count
105
+ n_cols = table.Columns.Count
106
+ slide = table_shape.Parent # type: ignore[attr-defined]
107
+
108
+ col_widths = [table.Columns(c).Width for c in range(1, n_cols + 1)]
109
+ row_heights = [table.Rows(r).Height for r in range(1, n_rows + 1)]
110
+ col_offsets = compute_spaced_positions(col_widths, GAP_RATIO)
111
+ row_offsets = compute_spaced_positions(row_heights, GAP_RATIO)
112
+
113
+ table_left = table_shape.Left # type: ignore[attr-defined]
114
+ table_top = table_shape.Top # type: ignore[attr-defined]
115
+
116
+ created = 0
117
+ for r in range(1, n_rows + 1):
118
+ for c in range(1, n_cols + 1):
119
+ cell = table.Cell(r, c)
120
+ cell_shape = cell.Shape
121
+ new_left = table_left + col_offsets[c - 1][0]
122
+ new_top = table_top + row_offsets[r - 1][0]
123
+ width = col_widths[c - 1]
124
+ height = row_heights[r - 1]
125
+
126
+ rect = slide.Shapes.AddShape(
127
+ _MSO_SHAPE_RECTANGLE, new_left, new_top, width, height
128
+ )
129
+ self._apply_cell_style(cell, cell_shape, rect, r, c)
130
+ created += 1
131
+
132
+ table_shape.Delete() # type: ignore[attr-defined]
133
+ return created
134
+
135
+ def _apply_cell_style(
136
+ self, cell: object, cell_shape: object, rect: object, row: int, col: int
137
+ ) -> None:
138
+ text_range = cell_shape.TextFrame.TextRange # type: ignore[attr-defined]
139
+ rect.TextFrame.TextRange.Text = text_range.Text # type: ignore[attr-defined]
140
+
141
+ font = text_range.Font
142
+ rect_font = rect.TextFrame.TextRange.Font # type: ignore[attr-defined]
143
+ if font.Name is not None:
144
+ rect_font.Name = font.Name
145
+ if font.Size is not None:
146
+ rect_font.Size = font.Size
147
+ if font.Bold is not None:
148
+ rect_font.Bold = font.Bold
149
+ try:
150
+ rect_font.Color.RGB = font.Color.RGB
151
+ except Exception:
152
+ pass
153
+ try:
154
+ rect.TextFrame.TextRange.ParagraphFormat.Alignment = ( # type: ignore[attr-defined]
155
+ text_range.ParagraphFormat.Alignment
156
+ )
157
+ except Exception:
158
+ pass
159
+
160
+ try:
161
+ fill = cell_shape.Fill # type: ignore[attr-defined]
162
+ rect.Fill.Visible = fill.Visible # type: ignore[attr-defined]
163
+ if fill.Visible:
164
+ rect.Fill.ForeColor.RGB = fill.ForeColor.RGB # type: ignore[attr-defined]
165
+ except Exception:
166
+ pass
167
+
168
+ self._apply_borders(cell, rect, row, col)
169
+
170
+ def _apply_borders(self, cell: object, rect: object, row: int, col: int) -> None:
171
+ try:
172
+ borders = [cell.Borders(side) for side in _BORDER_SIDES] # type: ignore[attr-defined]
173
+ weights = [b.Weight for b in borders]
174
+ colors = [b.ForeColor.RGB for b in borders]
175
+ visibles = [b.Visible for b in borders]
176
+ except Exception:
177
+ return
178
+
179
+ uniform = (
180
+ len(set(weights)) == 1 and len(set(colors)) == 1 and len(set(visibles)) == 1
181
+ )
182
+ if not uniform:
183
+ logger.warning(
184
+ "セル(%d,%d)の罫線が辺ごとに異なるため、上辺の値で統一しました", row, col
185
+ )
186
+
187
+ rect.Line.Weight = weights[0] # type: ignore[attr-defined]
188
+ rect.Line.ForeColor.RGB = colors[0] # type: ignore[attr-defined]
189
+ rect.Line.Visible = visibles[0] # type: ignore[attr-defined]
190
+
191
+ # --- 合成: 四角形群 -> 表 ---
192
+
193
+ def _run_compose(self, app: object, shapes: list[object]) -> int:
194
+ grid_shapes = [
195
+ GridShape(
196
+ left=s.Left, # type: ignore[attr-defined]
197
+ top=s.Top, # type: ignore[attr-defined]
198
+ width=s.Width, # type: ignore[attr-defined]
199
+ height=s.Height, # type: ignore[attr-defined]
200
+ ref=s,
201
+ )
202
+ for s in shapes
203
+ ]
204
+
205
+ try:
206
+ positions, n_rows, n_cols = estimate_grid(grid_shapes, GRID_TOLERANCE)
207
+ except DuplicateGridPositionError as exc:
208
+ raise SystemExit(str(exc)) from exc
209
+
210
+ missing = n_rows * n_cols - len(positions)
211
+ if missing:
212
+ logger.warning("歯抜けのグリッド位置: %d件", missing)
213
+
214
+ slide = shapes[0].Parent # type: ignore[attr-defined]
215
+
216
+ overall_left = min(s.left for s in grid_shapes)
217
+ overall_top = min(s.top for s in grid_shapes)
218
+ overall_right = max(s.left + s.width for s in grid_shapes)
219
+ overall_bottom = max(s.top + s.height for s in grid_shapes)
220
+
221
+ try:
222
+ app.StartNewUndoEntry() # type: ignore[attr-defined]
223
+ table_shape = slide.Shapes.AddTable(
224
+ n_rows,
225
+ n_cols,
226
+ overall_left,
227
+ overall_top,
228
+ overall_right - overall_left,
229
+ overall_bottom - overall_top,
230
+ )
231
+ table = table_shape.Table
232
+
233
+ col_widths: dict[int, float] = {}
234
+ row_heights: dict[int, float] = {}
235
+ for pos in positions:
236
+ col_widths.setdefault(pos.col, pos.shape.width)
237
+ row_heights.setdefault(pos.row, pos.shape.height)
238
+ self._copy_shape_to_cell(pos.shape.ref, table.Cell(pos.row + 1, pos.col + 1))
239
+
240
+ for col, width in col_widths.items():
241
+ table.Columns(col + 1).Width = width
242
+ for row, height in row_heights.items():
243
+ table.Rows(row + 1).Height = height
244
+
245
+ for shape in shapes:
246
+ shape.Delete() # type: ignore[attr-defined]
247
+ except Exception as exc:
248
+ raise SystemExit(
249
+ f"PowerPointの操作中にエラーが発生しました(Ctrl+Zで開始前の状態に戻せます): {exc}"
250
+ ) from exc
251
+
252
+ logger.info(
253
+ "推定グリッド=%d行%d列 対象シェイプ数=%d 歯抜け=%d",
254
+ n_rows,
255
+ n_cols,
256
+ len(shapes),
257
+ missing,
258
+ )
259
+ print(f"{len(shapes)}個のシェイプを1個の表({n_rows}行{n_cols}列)に合成しました")
260
+ return 0
261
+
262
+ def _copy_shape_to_cell(self, shape: object, cell: object) -> None:
263
+ cell_shape = cell.Shape # type: ignore[attr-defined]
264
+ cell_shape.TextFrame.TextRange.Text = shape.TextFrame.TextRange.Text # type: ignore[attr-defined]
265
+
266
+ font = shape.TextFrame.TextRange.Font # type: ignore[attr-defined]
267
+ cell_font = cell_shape.TextFrame.TextRange.Font
268
+ if font.Name is not None:
269
+ cell_font.Name = font.Name
270
+ if font.Size is not None:
271
+ cell_font.Size = font.Size
272
+ if font.Bold is not None:
273
+ cell_font.Bold = font.Bold
274
+
275
+ try:
276
+ fill = shape.Fill # type: ignore[attr-defined]
277
+ cell_shape.Fill.Visible = fill.Visible
278
+ if fill.Visible:
279
+ cell_shape.Fill.ForeColor.RGB = fill.ForeColor.RGB
280
+ except Exception:
281
+ pass
282
+
283
+ try:
284
+ line = shape.Line # type: ignore[attr-defined]
285
+ for side in _BORDER_SIDES:
286
+ border = cell.Borders(side) # type: ignore[attr-defined]
287
+ border.Weight = line.Weight
288
+ border.ForeColor.RGB = line.ForeColor.RGB
289
+ border.Visible = line.Visible
290
+ except Exception:
291
+ pass
292
+
293
+ # --- 行分割: 複数行テキスト -> 行ごとのシェイプ ---
294
+
295
+ def _run_line_split(self, app: object, shape: object) -> int:
296
+ if not getattr(shape, "HasTextFrame", False):
297
+ print("変換対象がありません")
298
+ return 0
299
+
300
+ text_range = shape.TextFrame.TextRange # type: ignore[attr-defined]
301
+ if not text_range.Text or not text_range.Text.strip():
302
+ print("変換対象がありません")
303
+ return 0
304
+
305
+ lines_range = text_range.Lines()
306
+ line_count = lines_range.Count
307
+ if line_count <= 1:
308
+ print("変換対象がありません")
309
+ return 0
310
+
311
+ lines = []
312
+ for i in range(1, line_count + 1):
313
+ line = text_range.Lines(i, 1)
314
+ text = line.Text.rstrip("\r")
315
+ if text.strip():
316
+ lines.append((text, line.Font))
317
+
318
+ skipped = line_count - len(lines)
319
+ if skipped:
320
+ logger.info("空行をスキップしました: %d件", skipped)
321
+
322
+ if not lines:
323
+ print("変換対象がありません")
324
+ return 0
325
+
326
+ slide = shape.Parent # type: ignore[attr-defined]
327
+ left = shape.Left # type: ignore[attr-defined]
328
+ width = shape.Width # type: ignore[attr-defined]
329
+ line_height = shape.Height / len(lines) # type: ignore[attr-defined]
330
+ offsets = compute_spaced_positions([line_height] * len(lines), GAP_RATIO)
331
+ top = shape.Top # type: ignore[attr-defined]
332
+
333
+ try:
334
+ app.StartNewUndoEntry() # type: ignore[attr-defined]
335
+ for (text, font), (offset, _gap) in zip(lines, offsets, strict=True):
336
+ new_box = slide.Shapes.AddTextbox(1, left, top + offset, width, line_height)
337
+ new_range = new_box.TextFrame.TextRange
338
+ new_range.Text = text
339
+ if font.Name is not None:
340
+ new_range.Font.Name = font.Name
341
+ if font.Size is not None:
342
+ new_range.Font.Size = font.Size
343
+ if font.Bold is not None:
344
+ new_range.Font.Bold = font.Bold
345
+ try:
346
+ new_range.Font.Color.RGB = font.Color.RGB
347
+ except Exception:
348
+ pass
349
+
350
+ shape.Delete() # type: ignore[attr-defined]
351
+ except Exception as exc:
352
+ raise SystemExit(
353
+ f"PowerPointの操作中にエラーが発生しました(Ctrl+Zで開始前の状態に戻せます): {exc}"
354
+ ) from exc
355
+
356
+ logger.info("分割した行数=%d スキップした空行数=%d", len(lines), skipped)
357
+ print(f"1個のテキストを{len(lines)}行のシェイプに分割しました")
358
+ return 0
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ from rembg import remove
7
+
8
+ from workpytools.common.clipboard import load_image
9
+ from workpytools.common.output import describe_output, save_result
10
+ from workpytools.processing.base import Processor
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ToukaProcessor(Processor):
16
+ """Remove the background from an image, producing a transparent PNG.
17
+
18
+ Input source is auto-detected, and the default output location follows
19
+ whichever of the three it was:
20
+ - `path` given: read that image file (jpg/png/...); output defaults next
21
+ to it as `{stem}_touka.png`
22
+ - `path` omitted, clipboard holds a copied file (e.g. Ctrl+C on a file in
23
+ Explorer): output is saved under the OS temp dir as `{stem}_touka.png`
24
+ and the file is placed on the clipboard, ready to paste
25
+ - `path` omitted, clipboard holds raw image data (e.g. "Copy Image" in a
26
+ viewer): no file is written; the processed image is placed on the
27
+ clipboard as raw image data, ready to paste
28
+ """
29
+
30
+ name = "touka"
31
+ help = "画像の背景を透過する(ファイルパス/クリップボード入力対応)"
32
+
33
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
34
+ parser.add_argument(
35
+ "path",
36
+ nargs="?",
37
+ default=None,
38
+ help="画像ファイルパス。省略時はクリップボードの画像/コピーしたファイルを使用",
39
+ )
40
+ parser.add_argument(
41
+ "-o", "--output", default=None, help="出力先パス(省略時は自動生成、拡張子はpng)"
42
+ )
43
+
44
+ def run(self, args: argparse.Namespace) -> int:
45
+ loaded = load_image(args.path)
46
+ logger.info("background removal starting (size=%s)", loaded.image.size)
47
+
48
+ result = remove(loaded.image)
49
+
50
+ output_path = save_result(loaded, result, "touka", args.output)
51
+ print(describe_output(output_path))
52
+ return 0
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from workpytools.common.clipboard import copy_text_to_clipboard
9
+ from workpytools.common.config import ConfigLocationError, vv_prompts_dir
10
+ from workpytools.common.textfile import (
11
+ TextFileError,
12
+ display_width,
13
+ pad_to_width,
14
+ read_text_with_fallback,
15
+ to_crlf,
16
+ truncate_to_width,
17
+ )
18
+ from workpytools.processing.base import Processor
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _PREVIEW_WIDTH = 46
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Prompt:
27
+ name: str
28
+ path: Path
29
+
30
+
31
+ def list_prompts(prompts_dir: Path) -> list[Prompt]:
32
+ """Collect prompt .txt files directly under `prompts_dir`, sorted by file name.
33
+
34
+ Extension matching is case-insensitive (Windows filesystems don't
35
+ distinguish case, and glob's behavior there isn't guaranteed), and the
36
+ result is explicitly sorted rather than relying on iteration order.
37
+ """
38
+ if not prompts_dir.is_dir():
39
+ return []
40
+
41
+ files = [
42
+ entry
43
+ for entry in prompts_dir.iterdir()
44
+ if entry.is_file() and entry.name.lower().endswith(".txt")
45
+ ]
46
+ files.sort(key=lambda p: p.name)
47
+ return [Prompt(name=p.stem, path=p) for p in files]
48
+
49
+
50
+ def _first_non_blank_line(text: str) -> str:
51
+ for line in text.splitlines():
52
+ if line.strip():
53
+ return line.strip()
54
+ return ""
55
+
56
+
57
+ def format_prompt_list(prompts: list[Prompt]) -> list[str]:
58
+ """Render the numbered list, aligning columns by display width.
59
+
60
+ Alignment uses display width rather than `len()` because Japanese names
61
+ render twice as wide as their character count, which would otherwise
62
+ leave the preview column ragged.
63
+ """
64
+ number_width = len(str(len(prompts)))
65
+ name_width = max((display_width(p.name) for p in prompts), default=0)
66
+
67
+ lines: list[str] = []
68
+ for index, prompt in enumerate(prompts, start=1):
69
+ try:
70
+ body = read_text_with_fallback(prompt.path)
71
+ preview = truncate_to_width(_first_non_blank_line(body), _PREVIEW_WIDTH)
72
+ except (TextFileError, OSError):
73
+ # 一覧表示は他のプロンプトが見えることを優先し、読めないものは印だけ付ける
74
+ preview = "(読み込めません)"
75
+
76
+ number = str(index).rjust(number_width)
77
+ if preview:
78
+ lines.append(f"{number}: {pad_to_width(prompt.name, name_width)} {preview}")
79
+ else:
80
+ lines.append(f"{number}: {prompt.name}")
81
+
82
+ return lines
83
+
84
+
85
+ class VvProcessor(Processor):
86
+ """Copy a saved prompt to the clipboard, ready to paste with Ctrl+V.
87
+
88
+ Running with no argument only lists what's available (it never touches
89
+ the clipboard); passing a number copies that prompt immediately without
90
+ printing the list, which is the "one keystroke" path.
91
+ """
92
+
93
+ name = "vv"
94
+ help = "定型プロンプトをクリップボードにコピーする(引数なしで一覧表示)"
95
+
96
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
97
+ parser.add_argument(
98
+ "number",
99
+ nargs="?",
100
+ default=None,
101
+ help="コピーするプロンプトの番号。省略時は一覧を表示するだけ",
102
+ )
103
+
104
+ def run(self, args: argparse.Namespace) -> int:
105
+ try:
106
+ prompts_dir = vv_prompts_dir()
107
+ except ConfigLocationError as exc:
108
+ raise SystemExit(str(exc)) from exc
109
+
110
+ prompts = list_prompts(prompts_dir)
111
+ if not prompts:
112
+ raise SystemExit(
113
+ f"プロンプトが登録されていません。{prompts_dir} に .txt を置いてください"
114
+ )
115
+
116
+ if args.number is None:
117
+ for line in format_prompt_list(prompts):
118
+ print(line)
119
+ return 0
120
+
121
+ index = self._parse_number(args.number, len(prompts))
122
+ prompt = prompts[index - 1]
123
+
124
+ try:
125
+ body = read_text_with_fallback(prompt.path)
126
+ except TextFileError as exc:
127
+ raise SystemExit(str(exc)) from exc
128
+ except OSError as exc:
129
+ raise SystemExit(f"ファイルを読み込めません: {prompt.path}") from exc
130
+
131
+ logger.info("プロンプトを読み込みました: %s", prompt.path)
132
+ copy_text_to_clipboard(to_crlf(body))
133
+ print(f"コピーしました: {prompt.name}")
134
+ return 0
135
+
136
+ def _parse_number(self, raw: str, count: int) -> int:
137
+ try:
138
+ number = int(raw)
139
+ except ValueError as exc:
140
+ raise SystemExit(
141
+ f"番号は数値で指定してください(1〜{count} の範囲): {raw!r}"
142
+ ) from exc
143
+
144
+ if not 1 <= number <= count:
145
+ raise SystemExit(f"番号は 1〜{count} の範囲で指定してください: {number}")
146
+
147
+ return number