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,47 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import webbrowser
6
+ from pathlib import Path
7
+
8
+ from workpytools.processing.base import Processor
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # リポジトリ直下の doc/help.html。commit時のpre-commitフックで最新化される
13
+ # (scripts/gen_help.py 参照)。このコマンド自体は生成せず、既存ファイルを開くだけ。
14
+ _HELP_HTML_PATH = Path(__file__).resolve().parents[3] / "doc" / "help.html"
15
+
16
+
17
+ class HelpProcessor(Processor):
18
+ """Open doc/help.html (a one-liner summary + full --help for every command)
19
+ in the default browser.
20
+
21
+ doc/help.html is regenerated by a pre-commit hook (scripts/gen_help.py)
22
+ whenever a command's help text changes, so it always reflects the last
23
+ committed state, not necessarily uncommitted local edits.
24
+ """
25
+
26
+ name = "help"
27
+ help = "全コマンドのヘルプ一覧(doc/help.html)をブラウザで開く"
28
+
29
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
30
+ parser.add_argument(
31
+ "--no-open", action="store_true", help="ブラウザを開かず、パスの表示のみ行う"
32
+ )
33
+
34
+ def run(self, args: argparse.Namespace) -> int:
35
+ if not _HELP_HTML_PATH.exists():
36
+ raise SystemExit(
37
+ f"help.htmlが見つかりません: {_HELP_HTML_PATH}\n"
38
+ "python scripts/gen_help.py を実行して生成してください。"
39
+ )
40
+
41
+ logger.info("ヘルプページを開きます: %s", _HELP_HTML_PATH)
42
+ print(_HELP_HTML_PATH)
43
+
44
+ if not args.no_open:
45
+ webbrowser.open(_HELP_HTML_PATH.as_uri())
46
+
47
+ return 0
@@ -0,0 +1,205 @@
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.shape_cluster import (
13
+ LEFT_TOLERANCE,
14
+ LINE_STEP_MAX_RATIO,
15
+ LINE_STEP_MIN_RATIO,
16
+ ShapeInfo,
17
+ cluster_shapes,
18
+ )
19
+ from workpytools.processing.base import Processor
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _SELECTION_SHAPES = 2 # ppSelectionShapes
24
+
25
+
26
+ class IkkoProcessor(Processor):
27
+ """Merge adjacent single-line text boxes on the current slide (or the
28
+ current selection) into one multi-paragraph text box. Meant for
29
+ PowerPoint's "convert to shapes" output, which splits each line of a
30
+ paragraph into its own text box.
31
+ """
32
+
33
+ name = "ikko"
34
+ help = "スライド上のバラバラなテキストボックスを1つに合体する"
35
+
36
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
37
+ parser.add_argument(
38
+ "--dry-run",
39
+ action="store_true",
40
+ help="実際には変更せず、合体対象になるクラスタを表示するだけにする",
41
+ )
42
+ parser.add_argument(
43
+ "--left-tolerance",
44
+ type=float,
45
+ default=LEFT_TOLERANCE,
46
+ help=f"左端座標の許容誤差(ポイント、既定{LEFT_TOLERANCE})",
47
+ )
48
+ parser.add_argument(
49
+ "--line-step-min",
50
+ type=float,
51
+ default=LINE_STEP_MIN_RATIO,
52
+ help=f"行送りの下限(フォントサイズ比、既定{LINE_STEP_MIN_RATIO})",
53
+ )
54
+ parser.add_argument(
55
+ "--line-step-max",
56
+ type=float,
57
+ default=LINE_STEP_MAX_RATIO,
58
+ help=f"行送りの上限(フォントサイズ比、既定{LINE_STEP_MAX_RATIO})",
59
+ )
60
+
61
+ def run(self, args: argparse.Namespace) -> int:
62
+ try:
63
+ app = get_running_powerpoint()
64
+ except PowerPointNotRunningError as exc:
65
+ raise SystemExit(
66
+ "PowerPointでプレゼンテーションを開いた状態で実行してください"
67
+ ) from exc
68
+
69
+ try:
70
+ get_active_presentation(app)
71
+ except NoActivePresentationError as exc:
72
+ raise SystemExit(
73
+ "PowerPointでプレゼンテーションを開いた状態で実行してください"
74
+ ) from exc
75
+
76
+ try:
77
+ com_shapes = self._resolve_target_shapes(app)
78
+ except Exception as exc:
79
+ raise SystemExit(f"対象範囲の取得中にエラーが発生しました: {exc}") from exc
80
+
81
+ shape_infos = [
82
+ info for info in (self._to_shape_info(s) for s in com_shapes) if info is not None
83
+ ]
84
+
85
+ if not shape_infos:
86
+ print("対象になるテキストボックスがありません")
87
+ return 0
88
+
89
+ clusters = [c for c in cluster_shapes(
90
+ shape_infos,
91
+ left_tolerance=args.left_tolerance,
92
+ line_step_min_ratio=args.line_step_min,
93
+ line_step_max_ratio=args.line_step_max,
94
+ ) if len(c) >= 2]
95
+
96
+ if not clusters:
97
+ print("合体できる組み合わせが見つかりませんでした(--left-tolerance等のオプションで閾値を調整できます)")
98
+ return 0
99
+
100
+ if args.dry_run:
101
+ self._print_dry_run(clusters)
102
+ return 0
103
+
104
+ try:
105
+ app.StartNewUndoEntry()
106
+ merged_count = 0
107
+ removed_count = 0
108
+ for cluster in clusters:
109
+ self._merge_cluster(cluster)
110
+ merged_count += 1
111
+ removed_count += len(cluster) - 1
112
+ except Exception as exc:
113
+ raise SystemExit(
114
+ f"PowerPointの操作中にエラーが発生しました(Ctrl+Zで開始前の状態に戻せます): {exc}"
115
+ ) from exc
116
+
117
+ logger.info("合体したクラスタ数=%d 削除したシェイプ数=%d", merged_count, removed_count)
118
+ total_before = removed_count + merged_count
119
+ print(f"{merged_count}個のクラスタを合体しました({total_before}個のシェイプを{merged_count}個に)")
120
+ return 0
121
+
122
+ def _resolve_target_shapes(self, app: object) -> list[object]:
123
+ selection = app.ActiveWindow.Selection # type: ignore[attr-defined]
124
+ if selection.Type == _SELECTION_SHAPES:
125
+ shape_range = selection.ShapeRange
126
+ return [shape_range.Item(i) for i in range(1, shape_range.Count + 1)]
127
+
128
+ slide = app.ActiveWindow.View.Slide # type: ignore[attr-defined]
129
+ return [slide.Shapes.Item(i) for i in range(1, slide.Shapes.Count + 1)]
130
+
131
+ def _to_shape_info(self, shape: object) -> ShapeInfo | None:
132
+ if not getattr(shape, "HasTextFrame", False):
133
+ return None
134
+ text_range = shape.TextFrame.TextRange # type: ignore[attr-defined]
135
+ text = text_range.Text
136
+ if not text or not text.strip():
137
+ return None
138
+ if text_range.Paragraphs().Count != 1:
139
+ return None
140
+
141
+ font = text_range.Font
142
+ try:
143
+ color = font.Color.RGB
144
+ except Exception:
145
+ color = None
146
+
147
+ if font.Name is None or font.Size is None:
148
+ return None
149
+
150
+ return ShapeInfo(
151
+ left=shape.Left, # type: ignore[attr-defined]
152
+ top=shape.Top, # type: ignore[attr-defined]
153
+ width=shape.Width, # type: ignore[attr-defined]
154
+ height=shape.Height, # type: ignore[attr-defined]
155
+ text=text,
156
+ font_name=font.Name,
157
+ font_size=font.Size,
158
+ bold=font.Bold,
159
+ color=color,
160
+ alignment=text_range.ParagraphFormat.Alignment,
161
+ ref=shape,
162
+ )
163
+
164
+ def _print_dry_run(self, clusters: list[list[ShapeInfo]]) -> None:
165
+ for i, cluster in enumerate(clusters, start=1):
166
+ print(f"クラスタ{i}: {len(cluster)}個のシェイプ")
167
+ for info in cluster:
168
+ print(f" Left={info.left:.1f} Top={info.top:.1f} Text={info.text!r}")
169
+
170
+ def _merge_cluster(self, cluster: list[ShapeInfo]) -> None:
171
+ lefts = [c.left for c in cluster]
172
+ tops = [c.top for c in cluster]
173
+ rights = [c.left + c.width for c in cluster]
174
+ bottoms = [c.top + c.height for c in cluster]
175
+
176
+ new_left = min(lefts)
177
+ new_top = min(tops)
178
+ new_width = max(rights) - new_left
179
+ new_height = max(bottoms) - new_top
180
+
181
+ style = cluster[0]
182
+ base_shape = cluster[0].ref
183
+ lines = [c.text for c in cluster]
184
+
185
+ text_range = base_shape.TextFrame.TextRange # type: ignore[attr-defined]
186
+ text_range.Text = "\r".join(lines)
187
+
188
+ if style.font_name is not None:
189
+ text_range.Font.Name = style.font_name
190
+ if style.font_size is not None:
191
+ text_range.Font.Size = style.font_size
192
+ if style.bold is not None:
193
+ text_range.Font.Bold = style.bold
194
+ if style.color is not None:
195
+ text_range.Font.Color.RGB = style.color
196
+ if style.alignment is not None:
197
+ text_range.ParagraphFormat.Alignment = style.alignment
198
+
199
+ base_shape.Left = new_left # type: ignore[attr-defined]
200
+ base_shape.Top = new_top # type: ignore[attr-defined]
201
+ base_shape.Width = new_width # type: ignore[attr-defined]
202
+ base_shape.Height = new_height # type: ignore[attr-defined]
203
+
204
+ for info in cluster[1:]:
205
+ info.ref.Delete() # type: ignore[attr-defined]
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ import cv2
7
+ import numpy as np
8
+ from PIL import Image
9
+
10
+ from workpytools.common.clipboard import load_image
11
+ from workpytools.common.output import describe_output, save_result
12
+ from workpytools.processing.base import Processor
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _BILATERAL_DIAMETER = 9
17
+ _UNSHARP_BLUR_SIGMA = 3.0
18
+
19
+
20
+ class KukiriProcessor(Processor):
21
+ """Clean up JPEG edge bleeding/ringing and sharpen boundaries.
22
+
23
+ Intended for flat-design illustrations where JPEG compression has
24
+ blurred/smudged the boundary between flat color regions. Applies an
25
+ edge-preserving bilateral filter (smooths within regions, leaves edges
26
+ intact) followed by an unsharp mask (boosts edge contrast).
27
+
28
+ Input source and default output location follow the same convention as
29
+ touka/denoise: explicit `path` or a file copied in Explorer saves next
30
+ to (or, for the clipboard-file case, into the OS temp dir and back onto
31
+ the clipboard as) `{stem}_kukiri.png`; raw clipboard image data skips
32
+ the file and is placed directly on the clipboard as image data.
33
+ """
34
+
35
+ name = "kukiri"
36
+ help = "JPEGの輪郭滲みを除去し境界をくっきりさせる(フラットイラスト向け)"
37
+
38
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
39
+ parser.add_argument(
40
+ "path",
41
+ nargs="?",
42
+ default=None,
43
+ help="画像ファイルパス。省略時はクリップボードの画像/コピーしたファイルを使用",
44
+ )
45
+ parser.add_argument(
46
+ "-o", "--output", default=None, help="出力先パス(省略時は自動生成、拡張子はpng)"
47
+ )
48
+ parser.add_argument(
49
+ "--smooth",
50
+ type=float,
51
+ default=75.0,
52
+ help="バイラテラルフィルタの強さ(デフォルト: 75.0)。"
53
+ "大きいほど滲みは消えるが細部も失われる",
54
+ )
55
+ parser.add_argument(
56
+ "--sharpen",
57
+ type=float,
58
+ default=0.5,
59
+ help="アンシャープマスクの強さ(デフォルト: 0.5、0で無効)。"
60
+ "大きいほど境界のコントラストが強調される",
61
+ )
62
+
63
+ def run(self, args: argparse.Namespace) -> int:
64
+ loaded = load_image(args.path)
65
+ logger.info(
66
+ "kukiri starting (size=%s, smooth=%s, sharpen=%s)",
67
+ loaded.image.size,
68
+ args.smooth,
69
+ args.sharpen,
70
+ )
71
+
72
+ result = self._process(loaded.image, smooth=args.smooth, sharpen=args.sharpen)
73
+
74
+ output_path = save_result(loaded, result, "kukiri", args.output)
75
+ print(describe_output(output_path))
76
+ return 0
77
+
78
+ @staticmethod
79
+ def _process(image: Image.Image, smooth: float, sharpen: float) -> Image.Image:
80
+ """Bilateral-filter then unsharp-mask, preserving the alpha channel untouched."""
81
+ rgba = np.array(image)
82
+ rgb = rgba[:, :, :3]
83
+ alpha = rgba[:, :, 3]
84
+
85
+ bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
86
+ smoothed = cv2.bilateralFilter(
87
+ bgr, d=_BILATERAL_DIAMETER, sigmaColor=smooth, sigmaSpace=smooth
88
+ )
89
+ blurred = cv2.GaussianBlur(smoothed, (0, 0), sigmaX=_UNSHARP_BLUR_SIGMA)
90
+ sharpened = cv2.addWeighted(smoothed, 1 + sharpen, blurred, -sharpen, 0)
91
+
92
+ result_rgb = cv2.cvtColor(sharpened, cv2.COLOR_BGR2RGB)
93
+ out = np.dstack([result_rgb, alpha])
94
+ return Image.fromarray(out, mode="RGBA")
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from workpytools.common.clipboard import copy_text_to_clipboard
9
+ from workpytools.common.walk import Entry, compute_total_sizes, dedupe_by_fullpath, walk
10
+ from workpytools.processing.base import Processor
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _DEFAULT_EXCLUDE = (".git", ".svn", "node_modules", "__pycache__")
15
+ _UNIT_DIVISORS = {"b": 1, "kb": 1024, "mb": 1024**2, "gb": 1024**3}
16
+
17
+
18
+ def _format_size(size: int | None, unit: str) -> str:
19
+ if size is None:
20
+ return ""
21
+ if unit == "b":
22
+ return str(size)
23
+ value = size / _UNIT_DIVISORS[unit]
24
+ return f"{value:.2f}"
25
+
26
+
27
+ def _resolve_link(fullpath: str) -> str:
28
+ """Resolve a .lnk shortcut target via WScript.Shell (a public COM API),
29
+ not by parsing the .lnk binary format ourselves.
30
+ """
31
+ import win32com.client
32
+
33
+ try:
34
+ shell = win32com.client.Dispatch("WScript.Shell")
35
+ shortcut = shell.CreateShortCut(fullpath)
36
+ return str(shortcut.TargetPath)
37
+ except Exception as exc:
38
+ logger.warning("ショートカットの解決に失敗しました: %s (%s)", fullpath, exc)
39
+ return ""
40
+
41
+
42
+ class LsdirProcessor(Processor):
43
+ """List files and directories under one or more roots as a flat table,
44
+ suitable for sorting/filtering in Excel. Column count is fixed for a
45
+ given invocation (no variable-length directory nesting columns).
46
+ """
47
+
48
+ name = "lsdir"
49
+ help = "フォルダ配下をExcelで集計できる表形式で一覧化する"
50
+
51
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
52
+ parser.add_argument("path", nargs="+", help="対象フォルダ(複数・グロブ可)")
53
+ parser.add_argument("-o", "--output", default=None, help="出力先パス(.tsv / .xlsx)")
54
+ parser.add_argument("--clip", action="store_true", help="結果をクリップボードにコピーする")
55
+ parser.add_argument(
56
+ "--files-only", action="store_true", help="ファイルのみ出力する"
57
+ )
58
+ parser.add_argument(
59
+ "--dirs-only", action="store_true", help="フォルダのみ出力する"
60
+ )
61
+ parser.add_argument(
62
+ "--exclude",
63
+ nargs="+",
64
+ default=list(_DEFAULT_EXCLUDE),
65
+ help="除外するフォルダ名(既定: %(default)s)",
66
+ )
67
+ parser.add_argument(
68
+ "--include-temp",
69
+ action="store_true",
70
+ help="'~$'で始まる一時ファイルも含める",
71
+ )
72
+ parser.add_argument(
73
+ "--total-size",
74
+ action="store_true",
75
+ help="フォルダ配下の合計サイズを計算する(全走査後に出力するため待ち時間が発生する)",
76
+ )
77
+ parser.add_argument(
78
+ "--unit",
79
+ choices=["b", "kb", "mb", "gb"],
80
+ default="kb",
81
+ help="サイズの単位(既定: kb)",
82
+ )
83
+ parser.add_argument(
84
+ "--resolve-link",
85
+ action="store_true",
86
+ help=".lnkのリンク先を解決する(WScript.Shell経由、大量にあると遅くなる)",
87
+ )
88
+ parser.add_argument(
89
+ "--encoding",
90
+ default="cp932" if os.name == "nt" else "utf-8",
91
+ help="出力エンコーディング",
92
+ )
93
+
94
+ def run(self, args: argparse.Namespace) -> int:
95
+ roots = self._resolve_roots(args.path)
96
+
97
+ all_entries: list[Entry] = []
98
+ total_skipped_dirs = 0
99
+ for root, source_label in roots:
100
+ entries, skipped = walk(
101
+ root,
102
+ source_label,
103
+ exclude=frozenset(args.exclude),
104
+ include_temp=args.include_temp,
105
+ )
106
+ all_entries.extend(entries)
107
+ total_skipped_dirs += skipped
108
+
109
+ if total_skipped_dirs:
110
+ logger.warning("アクセスできず読み飛ばしたフォルダ: %d件", total_skipped_dirs)
111
+
112
+ all_entries, dup_skipped = dedupe_by_fullpath(all_entries)
113
+ if dup_skipped:
114
+ logger.info("起点の重複により除外したエントリ: %d件", dup_skipped)
115
+
116
+ # ファイル行を落とす前に合計サイズを計算する必要がある
117
+ # (--dirs-only 指定時、ファイルのサイズ情報がないと配下の合計が出せない)。
118
+ total_sizes = compute_total_sizes(all_entries) if args.total_size else {}
119
+
120
+ if args.files_only:
121
+ all_entries = [e for e in all_entries if e.type == "file"]
122
+ elif args.dirs_only:
123
+ all_entries = [e for e in all_entries if e.type == "dir"]
124
+
125
+ link_by_path: dict[str, str] = {}
126
+ if args.resolve_link:
127
+ for entry in all_entries:
128
+ if entry.ext.lower() == ".lnk":
129
+ link_by_path[entry.fullpath] = _resolve_link(entry.fullpath)
130
+
131
+ header = ["source", "type", "name", "fullpath", "parent", "ext", "size", "mtime", "depth"]
132
+ if args.resolve_link:
133
+ header.append("link")
134
+
135
+ rows = [header]
136
+ for entry in all_entries:
137
+ if args.total_size and entry.type == "dir":
138
+ size_value: int | None = total_sizes.get(entry.fullpath, 0)
139
+ else:
140
+ size_value = entry.size
141
+
142
+ row = [
143
+ entry.source,
144
+ entry.type,
145
+ entry.name,
146
+ entry.fullpath,
147
+ entry.parent,
148
+ entry.ext,
149
+ _format_size(size_value, args.unit),
150
+ entry.mtime,
151
+ str(entry.depth),
152
+ ]
153
+ if args.resolve_link:
154
+ row.append(link_by_path.get(entry.fullpath, ""))
155
+ rows.append(row)
156
+
157
+ self._emit(rows, args)
158
+ return 0
159
+
160
+ def _resolve_roots(self, patterns: list[str]) -> list[tuple[str, str]]:
161
+ from glob import glob
162
+
163
+ roots = []
164
+ for pattern in patterns:
165
+ matches = glob(pattern) or [pattern]
166
+ for match in matches:
167
+ path = Path(match)
168
+ if not path.exists():
169
+ raise SystemExit(f"指定したパスが存在しません: {path}")
170
+ roots.append((str(path), match))
171
+ return roots
172
+
173
+ def _emit(self, rows: list[list[str]], args: argparse.Namespace) -> None:
174
+ tsv_text = "\n".join("\t".join(row) for row in rows)
175
+
176
+ if args.clip:
177
+ copy_text_to_clipboard(tsv_text)
178
+ print("一覧をコピーしました")
179
+ return
180
+
181
+ if args.output:
182
+ self._write_output(rows, tsv_text, args.output, args.encoding)
183
+ return
184
+
185
+ print(tsv_text)
186
+
187
+ def _write_output(
188
+ self, rows: list[list[str]], tsv_text: str, output: str, encoding: str
189
+ ) -> None:
190
+ output_path = Path(output)
191
+ if output_path.suffix.lower() == ".xlsx":
192
+ self._write_excel(rows, output_path)
193
+ else:
194
+ output_path.write_bytes(
195
+ tsv_text.encode(encoding, errors="backslashreplace")
196
+ )
197
+ logger.info("一覧を書き出しました: %s", output_path)
198
+ print(output_path)
199
+
200
+ def _write_excel(self, rows: list[list[str]], output_path: Path) -> None:
201
+ import xlsxwriter
202
+
203
+ header = rows[0]
204
+ size_col = header.index("size") if "size" in header else None
205
+
206
+ with xlsxwriter.Workbook(str(output_path)) as workbook:
207
+ worksheet = workbook.add_worksheet("lsdir")
208
+ header_format = workbook.add_format({"bold": True, "border": 1})
209
+ worksheet.write_row(0, 0, header, header_format)
210
+
211
+ for r, row in enumerate(rows[1:], start=1):
212
+ for c, value in enumerate(row):
213
+ if c == size_col and value:
214
+ worksheet.write_number(r, c, float(value))
215
+ else:
216
+ worksheet.write(r, c, value)
217
+
218
+ worksheet.autofilter(0, 0, len(rows) - 1, len(header) - 1)