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,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
|
|
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("変換できるテキストがありません")
|