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.
- workpytools/__init__.py +1 -0
- workpytools/cli.py +76 -0
- workpytools/common/__init__.py +0 -0
- workpytools/common/browser_preview.py +40 -0
- workpytools/common/clipboard.py +318 -0
- workpytools/common/clustering.py +85 -0
- workpytools/common/config.py +44 -0
- workpytools/common/embedding.py +101 -0
- workpytools/common/help_gen.py +542 -0
- workpytools/common/logging.py +12 -0
- workpytools/common/markdown_html.py +15 -0
- workpytools/common/outline_parse.py +81 -0
- workpytools/common/output.py +71 -0
- workpytools/common/powerpoint.py +43 -0
- workpytools/common/shape_cluster.py +75 -0
- workpytools/common/table_shapes.py +157 -0
- workpytools/common/tabular.py +248 -0
- workpytools/common/textfile.py +90 -0
- workpytools/common/walk.py +133 -0
- workpytools/data/__init__.py +0 -0
- workpytools/data/synonym.tsv +3 -0
- workpytools/data/user.dic +2 -0
- workpytools/processing/__init__.py +0 -0
- workpytools/processing/base.py +23 -0
- workpytools/processing/clipfmt.py +143 -0
- workpytools/processing/clipmd.py +237 -0
- workpytools/processing/clipview.py +194 -0
- workpytools/processing/cwc.py +427 -0
- workpytools/processing/denoise.py +81 -0
- workpytools/processing/help.py +47 -0
- workpytools/processing/ikko.py +205 -0
- workpytools/processing/kukiri.py +94 -0
- workpytools/processing/lsdir.py +218 -0
- workpytools/processing/mdtsv.py +177 -0
- workpytools/processing/mokuji.py +91 -0
- workpytools/processing/nagasa.py +94 -0
- workpytools/processing/outline.py +85 -0
- workpytools/processing/profiler.py +277 -0
- workpytools/processing/seiretsu.py +111 -0
- workpytools/processing/tbl.py +358 -0
- workpytools/processing/touka.py +52 -0
- workpytools/processing/vv.py +147 -0
- workpytools-0.1.4.dist-info/METADATA +458 -0
- workpytools-0.1.4.dist-info/RECORD +48 -0
- workpytools-0.1.4.dist-info/WHEEL +5 -0
- workpytools-0.1.4.dist-info/entry_points.txt +20 -0
- workpytools-0.1.4.dist-info/licenses/LICENSE +21 -0
- 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
|