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,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from workpytools.common.textfile import normalize_newlines
|
|
7
|
+
|
|
8
|
+
_HEADING_RE = re.compile(r"^#{1,6} .+")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class OutlineItem:
|
|
13
|
+
title: str
|
|
14
|
+
body: str # "\n"-joined when multi-line; "" when there's no body
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_outline(text: str) -> list[OutlineItem]:
|
|
18
|
+
"""Auto-detect one of three outline formats and extract title/body pairs.
|
|
19
|
+
|
|
20
|
+
Tried in order: Markdown headings, tab-separated lines, blank-line-
|
|
21
|
+
delimited blocks (the fallback for anything that matches neither).
|
|
22
|
+
"""
|
|
23
|
+
normalized = normalize_newlines(text)
|
|
24
|
+
lines = normalized.split("\n")
|
|
25
|
+
|
|
26
|
+
if any(_HEADING_RE.match(line) for line in lines):
|
|
27
|
+
return _parse_markdown_headings(lines)
|
|
28
|
+
if "\t" in normalized:
|
|
29
|
+
return _parse_tab_separated(lines)
|
|
30
|
+
return _parse_blank_line_blocks(normalized)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_markdown_headings(lines: list[str]) -> list[OutlineItem]:
|
|
34
|
+
items: list[OutlineItem] = []
|
|
35
|
+
title: str | None = None
|
|
36
|
+
body_lines: list[str] = []
|
|
37
|
+
|
|
38
|
+
def flush() -> None:
|
|
39
|
+
if title is not None:
|
|
40
|
+
trimmed = list(body_lines)
|
|
41
|
+
while trimmed and not trimmed[-1].strip():
|
|
42
|
+
trimmed.pop()
|
|
43
|
+
items.append(OutlineItem(title=title, body="\n".join(trimmed)))
|
|
44
|
+
|
|
45
|
+
for line in lines:
|
|
46
|
+
if _HEADING_RE.match(line):
|
|
47
|
+
flush()
|
|
48
|
+
title = line.split(" ", 1)[1].strip()
|
|
49
|
+
body_lines = []
|
|
50
|
+
elif title is not None:
|
|
51
|
+
body_lines.append(line)
|
|
52
|
+
flush()
|
|
53
|
+
|
|
54
|
+
return items
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_tab_separated(lines: list[str]) -> list[OutlineItem]:
|
|
58
|
+
items: list[OutlineItem] = []
|
|
59
|
+
for line in lines:
|
|
60
|
+
if not line.strip():
|
|
61
|
+
continue
|
|
62
|
+
if "\t" in line:
|
|
63
|
+
title, body = line.split("\t", 1)
|
|
64
|
+
items.append(OutlineItem(title=title.strip(), body=body.strip()))
|
|
65
|
+
else:
|
|
66
|
+
items.append(OutlineItem(title=line.strip(), body=""))
|
|
67
|
+
return items
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_blank_line_blocks(normalized: str) -> list[OutlineItem]:
|
|
71
|
+
blocks = re.split(r"\n\s*\n", normalized.strip())
|
|
72
|
+
items: list[OutlineItem] = []
|
|
73
|
+
for block in blocks:
|
|
74
|
+
block = block.strip("\n")
|
|
75
|
+
if not block.strip():
|
|
76
|
+
continue
|
|
77
|
+
block_lines = block.split("\n")
|
|
78
|
+
title = block_lines[0].strip()
|
|
79
|
+
body = "\n".join(block_lines[1:]).strip("\n")
|
|
80
|
+
items.append(OutlineItem(title=title, body=body))
|
|
81
|
+
return items
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from workpytools.common.clipboard import SourceKind, copy_file_to_clipboard, copy_image_to_clipboard
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HasSource(Protocol):
|
|
13
|
+
"""Structural type for anything that knows where its input data came from.
|
|
14
|
+
|
|
15
|
+
`LoadedImage` and `LoadedText` both satisfy this. Attributes are declared
|
|
16
|
+
as read-only properties because Protocol treats mutable attributes as
|
|
17
|
+
invariant — a plain `source_kind: SourceKind` field would fail mypy
|
|
18
|
+
strict unless the implementing class declares the exact same type.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def source_path(self) -> Path | None: ...
|
|
23
|
+
@property
|
|
24
|
+
def source_kind(self) -> SourceKind: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_result(
|
|
28
|
+
loaded: HasSource, result: Image.Image, command: str, output: str | None
|
|
29
|
+
) -> Path | None:
|
|
30
|
+
"""Save `result` following the shared output convention across all commands.
|
|
31
|
+
|
|
32
|
+
- `output` explicitly given: save there, no clipboard interaction.
|
|
33
|
+
- `source_kind == "path"`: save next to the source file as
|
|
34
|
+
`{stem}_{command}.png`.
|
|
35
|
+
- `source_kind == "clipboard_file"` (a file copied in Explorer): save
|
|
36
|
+
under the OS temp dir as `{stem}_{command}.png` and put that file on
|
|
37
|
+
the clipboard, ready to paste.
|
|
38
|
+
- otherwise (`clipboard_data` / `clipboard_text`, no source file): don't
|
|
39
|
+
save to disk; put the processed image directly on the clipboard as data.
|
|
40
|
+
|
|
41
|
+
Returns the path written to disk, or `None` when the result was only
|
|
42
|
+
placed on the clipboard as raw image data.
|
|
43
|
+
"""
|
|
44
|
+
if output is not None:
|
|
45
|
+
output_path = Path(output)
|
|
46
|
+
result.save(output_path)
|
|
47
|
+
return output_path
|
|
48
|
+
|
|
49
|
+
if loaded.source_kind == "path":
|
|
50
|
+
assert loaded.source_path is not None
|
|
51
|
+
output_path = loaded.source_path.with_name(f"{loaded.source_path.stem}_{command}.png")
|
|
52
|
+
result.save(output_path)
|
|
53
|
+
return output_path
|
|
54
|
+
|
|
55
|
+
if loaded.source_kind == "clipboard_file":
|
|
56
|
+
assert loaded.source_path is not None
|
|
57
|
+
tmpdir = Path(tempfile.gettempdir())
|
|
58
|
+
output_path = tmpdir / f"{loaded.source_path.stem}_{command}.png"
|
|
59
|
+
result.save(output_path)
|
|
60
|
+
copy_file_to_clipboard(output_path)
|
|
61
|
+
return output_path
|
|
62
|
+
|
|
63
|
+
copy_image_to_clipboard(result)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def describe_output(output_path: Path | None) -> str:
|
|
68
|
+
"""Human-readable line for `print()` after `save_result`."""
|
|
69
|
+
if output_path is not None:
|
|
70
|
+
return str(output_path)
|
|
71
|
+
return "(クリップボードに画像データをコピーしました)"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import win32com.client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PowerPointNotRunningError(RuntimeError):
|
|
9
|
+
"""Raised when no running PowerPoint instance can be attached to."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoActivePresentationError(RuntimeError):
|
|
13
|
+
"""Raised when PowerPoint is running but has no active window
|
|
14
|
+
(e.g. Presentations.Count == 0)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_running_powerpoint() -> Any:
|
|
18
|
+
"""Attach to an already-running PowerPoint Application via COM.
|
|
19
|
+
|
|
20
|
+
Never launches a new instance -- deciding whether to start one is left
|
|
21
|
+
to the caller, since that's a per-command policy (outline does; ikko
|
|
22
|
+
and mokuji don't).
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
return win32com.client.GetActiveObject("PowerPoint.Application")
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
raise PowerPointNotRunningError(
|
|
28
|
+
"PowerPointが起動していません"
|
|
29
|
+
) from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_active_presentation(app: Any) -> Any:
|
|
33
|
+
"""Return the presentation behind the focused window.
|
|
34
|
+
|
|
35
|
+
Uses ActiveWindow.Presentation rather than ActivePresentation so that,
|
|
36
|
+
with multiple PowerPoint windows open, the one currently focused wins.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return app.ActiveWindow.Presentation
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
raise NoActivePresentationError(
|
|
42
|
+
"PowerPointでプレゼンテーションが開かれていません"
|
|
43
|
+
) from exc
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
LEFT_TOLERANCE = 1.0 # points
|
|
6
|
+
LINE_STEP_MIN_RATIO = 0.8
|
|
7
|
+
LINE_STEP_MAX_RATIO = 2.2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ShapeInfo:
|
|
12
|
+
"""A text-carrying shape's geometry, style, and a reference back to the
|
|
13
|
+
underlying COM shape object (opaque to this module -- clustering never
|
|
14
|
+
touches it directly)."""
|
|
15
|
+
|
|
16
|
+
left: float
|
|
17
|
+
top: float
|
|
18
|
+
width: float
|
|
19
|
+
height: float
|
|
20
|
+
text: str
|
|
21
|
+
font_name: str | None
|
|
22
|
+
font_size: float | None
|
|
23
|
+
bold: int | None # COM returns 0 / -1, not a Python bool
|
|
24
|
+
color: int | None
|
|
25
|
+
alignment: int | None
|
|
26
|
+
ref: object
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cluster_shapes(
|
|
30
|
+
shapes: list[ShapeInfo],
|
|
31
|
+
left_tolerance: float = LEFT_TOLERANCE,
|
|
32
|
+
line_step_min_ratio: float = LINE_STEP_MIN_RATIO,
|
|
33
|
+
line_step_max_ratio: float = LINE_STEP_MAX_RATIO,
|
|
34
|
+
) -> list[list[ShapeInfo]]:
|
|
35
|
+
"""Group adjacent shapes that look like consecutive lines of one
|
|
36
|
+
paragraph, sorted left-then-top so clustering is independent of input
|
|
37
|
+
order.
|
|
38
|
+
"""
|
|
39
|
+
ordered = sorted(shapes, key=lambda s: (s.left, s.top))
|
|
40
|
+
if not ordered:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
clusters: list[list[ShapeInfo]] = [[ordered[0]]]
|
|
44
|
+
|
|
45
|
+
for prev, curr in zip(ordered, ordered[1:], strict=False):
|
|
46
|
+
if _same_cluster(prev, curr, left_tolerance, line_step_min_ratio, line_step_max_ratio):
|
|
47
|
+
clusters[-1].append(curr)
|
|
48
|
+
else:
|
|
49
|
+
clusters.append([curr])
|
|
50
|
+
|
|
51
|
+
return clusters
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _same_cluster(
|
|
55
|
+
prev: ShapeInfo,
|
|
56
|
+
curr: ShapeInfo,
|
|
57
|
+
left_tolerance: float,
|
|
58
|
+
line_step_min_ratio: float,
|
|
59
|
+
line_step_max_ratio: float,
|
|
60
|
+
) -> bool:
|
|
61
|
+
same_style = (
|
|
62
|
+
prev.font_name == curr.font_name
|
|
63
|
+
and prev.font_size == curr.font_size
|
|
64
|
+
and prev.bold == curr.bold
|
|
65
|
+
and prev.color == curr.color
|
|
66
|
+
)
|
|
67
|
+
same_left = abs(prev.left - curr.left) <= left_tolerance
|
|
68
|
+
|
|
69
|
+
size = prev.font_size
|
|
70
|
+
if not size:
|
|
71
|
+
return False
|
|
72
|
+
line_step = curr.top - prev.top
|
|
73
|
+
step_ok = (size * line_step_min_ratio) <= line_step <= (size * line_step_max_ratio)
|
|
74
|
+
|
|
75
|
+
return same_style and same_left and step_ok
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
GAP_RATIO = 0.05 # margin between decomposed shapes, as a fraction of cell size
|
|
6
|
+
GRID_TOLERANCE = 1.0 # points, same reasoning as ikko's LEFT_TOLERANCE
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CellSpacing:
|
|
11
|
+
"""Cumulative left/top offset and unchanged width/height for one cell,
|
|
12
|
+
after inserting a gap-ratio margin before each column/row."""
|
|
13
|
+
|
|
14
|
+
left: float
|
|
15
|
+
top: float
|
|
16
|
+
width: float
|
|
17
|
+
height: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_spaced_positions(
|
|
21
|
+
sizes: list[float], gap_ratio: float = GAP_RATIO
|
|
22
|
+
) -> list[tuple[float, float]]:
|
|
23
|
+
"""For a sequence of column widths (or row heights), return
|
|
24
|
+
(new_offset, gap_used) pairs computed with the cursor method: advance by
|
|
25
|
+
this cell's gap, record the offset, then advance by its size.
|
|
26
|
+
|
|
27
|
+
Offsets are relative to 0; callers add the table's own Left/Top.
|
|
28
|
+
"""
|
|
29
|
+
cursor = 0.0
|
|
30
|
+
result = []
|
|
31
|
+
for size in sizes:
|
|
32
|
+
gap = size * gap_ratio
|
|
33
|
+
cursor += gap
|
|
34
|
+
result.append((cursor, gap))
|
|
35
|
+
cursor += size
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class GridShape:
|
|
41
|
+
"""A shape's geometry and a reference back to the underlying COM object
|
|
42
|
+
(opaque to this module), used as input to grid estimation."""
|
|
43
|
+
|
|
44
|
+
left: float
|
|
45
|
+
top: float
|
|
46
|
+
width: float
|
|
47
|
+
height: float
|
|
48
|
+
ref: object
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class GridPosition:
|
|
53
|
+
row: int
|
|
54
|
+
col: int
|
|
55
|
+
shape: GridShape
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DuplicateGridPositionError(RuntimeError):
|
|
59
|
+
"""Raised when two or more shapes map to the same (row, col)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def estimate_grid(
|
|
63
|
+
shapes: list[GridShape], tolerance: float = GRID_TOLERANCE
|
|
64
|
+
) -> tuple[list[GridPosition], int, int]:
|
|
65
|
+
"""Cluster shapes' Left values into columns and Top values into rows,
|
|
66
|
+
independent of whether gaps between shapes are uniform.
|
|
67
|
+
|
|
68
|
+
Returns (positions, row_count, col_count). Raises
|
|
69
|
+
DuplicateGridPositionError if two shapes land on the same grid cell.
|
|
70
|
+
"""
|
|
71
|
+
col_starts = _cluster_axis([s.left for s in shapes], tolerance)
|
|
72
|
+
row_starts = _cluster_axis([s.top for s in shapes], tolerance)
|
|
73
|
+
|
|
74
|
+
positions: list[GridPosition] = []
|
|
75
|
+
seen: set[tuple[int, int]] = set()
|
|
76
|
+
for shape in shapes:
|
|
77
|
+
col = _index_of_cluster(shape.left, col_starts, tolerance)
|
|
78
|
+
row = _index_of_cluster(shape.top, row_starts, tolerance)
|
|
79
|
+
if (row, col) in seen:
|
|
80
|
+
raise DuplicateGridPositionError(
|
|
81
|
+
f"同じセル位置に複数のシェイプが重なっています (row={row}, col={col})"
|
|
82
|
+
)
|
|
83
|
+
seen.add((row, col))
|
|
84
|
+
positions.append(GridPosition(row=row, col=col, shape=shape))
|
|
85
|
+
|
|
86
|
+
return positions, len(row_starts), len(col_starts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cluster_axis(values: list[float], tolerance: float) -> list[float]:
|
|
90
|
+
"""Sort and greedily chain-merge values within `tolerance` of the
|
|
91
|
+
previous value, so a run like 0.0, 0.9, 1.8, 2.7 (each step within
|
|
92
|
+
tolerance, but the ends 2.7 apart) becomes one cluster rather than
|
|
93
|
+
splitting on absolute distance from the first member. This intentionally
|
|
94
|
+
favors simplicity over guarding against chained drift, since a table's
|
|
95
|
+
columns/rows are normally cleanly separated in practice.
|
|
96
|
+
"""
|
|
97
|
+
ordered = sorted(values)
|
|
98
|
+
clusters: list[float] = []
|
|
99
|
+
for value in ordered:
|
|
100
|
+
if not clusters or value - clusters[-1] > tolerance:
|
|
101
|
+
clusters.append(value)
|
|
102
|
+
return clusters
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _index_of_cluster(value: float, cluster_starts: list[float], tolerance: float) -> int:
|
|
106
|
+
"""Find which cluster `value` belongs to, using the same chained
|
|
107
|
+
tolerance test as `_cluster_axis`: the closest cluster start at or
|
|
108
|
+
below `value` within `tolerance`, falling back to nearest overall.
|
|
109
|
+
"""
|
|
110
|
+
best_index = 0
|
|
111
|
+
best_distance = abs(value - cluster_starts[0])
|
|
112
|
+
for i, start in enumerate(cluster_starts):
|
|
113
|
+
distance = abs(value - start)
|
|
114
|
+
if distance < best_distance:
|
|
115
|
+
best_distance = distance
|
|
116
|
+
best_index = i
|
|
117
|
+
return best_index
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def column_and_row_sizes(
|
|
121
|
+
positions: list[GridPosition], n_rows: int, n_cols: int
|
|
122
|
+
) -> tuple[list[float], list[float]]:
|
|
123
|
+
"""Per-column max width and per-row max height, taken only from shapes
|
|
124
|
+
that actually landed in that column/row. A cell (row, col) can be a gap
|
|
125
|
+
(no shape there) and still get a nonzero size from other shapes sharing
|
|
126
|
+
its row or column; only a row/col with zero shapes anywhere in it would
|
|
127
|
+
read 0.0, which can't happen here since estimate_grid only ever
|
|
128
|
+
produces rows/columns backed by at least one real shape's coordinate.
|
|
129
|
+
"""
|
|
130
|
+
col_width = [0.0] * n_cols
|
|
131
|
+
row_height = [0.0] * n_rows
|
|
132
|
+
for pos in positions:
|
|
133
|
+
col_width[pos.col] = max(col_width[pos.col], pos.shape.width)
|
|
134
|
+
row_height[pos.row] = max(row_height[pos.row], pos.shape.height)
|
|
135
|
+
return col_width, row_height
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def grid_centers(
|
|
139
|
+
col_width: list[float], row_height: list[float], overall_left: float, overall_top: float
|
|
140
|
+
) -> tuple[list[float], list[float]]:
|
|
141
|
+
"""Center x/y of each column/row, laid out with the cursor method
|
|
142
|
+
(no gap between cells -- sizes alone determine the pitch) starting at
|
|
143
|
+
the grid's own top-left corner.
|
|
144
|
+
"""
|
|
145
|
+
center_x = []
|
|
146
|
+
cursor_x = overall_left
|
|
147
|
+
for width in col_width:
|
|
148
|
+
center_x.append(cursor_x + width / 2)
|
|
149
|
+
cursor_x += width
|
|
150
|
+
|
|
151
|
+
center_y = []
|
|
152
|
+
cursor_y = overall_top
|
|
153
|
+
for height in row_height:
|
|
154
|
+
center_y.append(cursor_y + height / 2)
|
|
155
|
+
cursor_y += height
|
|
156
|
+
|
|
157
|
+
return center_x, center_y
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from itertools import zip_longest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from workpytools.common.textfile import read_text_with_fallback
|
|
12
|
+
|
|
13
|
+
DEFAULT_EMPTY_VALUES = frozenset(
|
|
14
|
+
{None, "", "N/A", "NULL", "null", "None", "none", "-", "NaN"}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_BOOL_TAG_PREFIX = "\x00bool\x00"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Table:
|
|
22
|
+
"""A single sheet/file worth of tabular data: column names plus row-major values."""
|
|
23
|
+
|
|
24
|
+
columns: list[str]
|
|
25
|
+
rows: list[tuple[object, ...]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ColumnProfile:
|
|
30
|
+
column: str
|
|
31
|
+
rows: int
|
|
32
|
+
filled: int
|
|
33
|
+
empty: int
|
|
34
|
+
unique: int
|
|
35
|
+
is_filled: bool
|
|
36
|
+
is_unique: bool
|
|
37
|
+
fill_rate: float
|
|
38
|
+
unique_rate: float
|
|
39
|
+
key_score: float
|
|
40
|
+
top: list[tuple[str, int]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_value(value: object) -> object:
|
|
44
|
+
"""Fold Excel's int/float ambiguity (1 vs 1.0) and trim incidental whitespace.
|
|
45
|
+
|
|
46
|
+
Values are compared as strings elsewhere, but this keeps `1` and `1.0`
|
|
47
|
+
(a common artifact of Excel cell formatting) from being counted as
|
|
48
|
+
distinct values. `bool` is tagged so `True` (the value) and `"True"`
|
|
49
|
+
(the string) don't collide once both are stringified.
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
return f"{_BOOL_TAG_PREFIX}{value}"
|
|
53
|
+
if isinstance(value, float) and value.is_integer():
|
|
54
|
+
return str(int(value))
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
return value.strip()
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
return str(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_empty(value: object, empty_values: frozenset[object]) -> bool:
|
|
63
|
+
if value in empty_values:
|
|
64
|
+
return True
|
|
65
|
+
if isinstance(value, str) and value.strip() == "":
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def profile_columns(
|
|
71
|
+
table: Table,
|
|
72
|
+
top_n: int = 10,
|
|
73
|
+
empty_values: frozenset[object] = DEFAULT_EMPTY_VALUES,
|
|
74
|
+
) -> list[ColumnProfile]:
|
|
75
|
+
"""Profile every column of `table`.
|
|
76
|
+
|
|
77
|
+
Rows are transposed with `zip_longest` before counting (measured faster
|
|
78
|
+
than a single pass that updates one Counter per column per row: see
|
|
79
|
+
doc/profiler.md for the benchmark). Ragged rows are padded with None by
|
|
80
|
+
`zip_longest`, so uneven row lengths don't misalign columns.
|
|
81
|
+
"""
|
|
82
|
+
n_rows = len(table.rows)
|
|
83
|
+
results: list[ColumnProfile] = []
|
|
84
|
+
|
|
85
|
+
if n_rows == 0:
|
|
86
|
+
columns_by_index: list[tuple[int, tuple[object, ...]]] = [
|
|
87
|
+
(i, ()) for i in range(len(table.columns))
|
|
88
|
+
]
|
|
89
|
+
else:
|
|
90
|
+
columns_by_index = list(enumerate(zip_longest(*table.rows)))
|
|
91
|
+
|
|
92
|
+
for col_idx, raw_values in columns_by_index:
|
|
93
|
+
name = table.columns[col_idx] if col_idx < len(table.columns) else str(col_idx)
|
|
94
|
+
normalized = [normalize_value(v) for v in raw_values]
|
|
95
|
+
counter = Counter(v for v in normalized if not is_empty(v, empty_values))
|
|
96
|
+
|
|
97
|
+
filled = sum(counter.values())
|
|
98
|
+
empty = n_rows - filled
|
|
99
|
+
unique = len(counter)
|
|
100
|
+
|
|
101
|
+
fill_rate = filled / n_rows if n_rows else 0.0
|
|
102
|
+
unique_rate = unique / n_rows if n_rows else 0.0
|
|
103
|
+
key_score = _harmonic_mean(fill_rate, unique_rate)
|
|
104
|
+
|
|
105
|
+
top = counter.most_common(top_n) if top_n > 0 else []
|
|
106
|
+
|
|
107
|
+
results.append(
|
|
108
|
+
ColumnProfile(
|
|
109
|
+
column=name,
|
|
110
|
+
rows=n_rows,
|
|
111
|
+
filled=filled,
|
|
112
|
+
empty=empty,
|
|
113
|
+
unique=unique,
|
|
114
|
+
is_filled=filled == n_rows,
|
|
115
|
+
is_unique=unique == n_rows,
|
|
116
|
+
fill_rate=fill_rate,
|
|
117
|
+
unique_rate=unique_rate,
|
|
118
|
+
key_score=key_score,
|
|
119
|
+
top=[(_display_value(v), c) for v, c in top],
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return results
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _display_value(value: object) -> str:
|
|
127
|
+
"""Undo normalize_value's internal bool tagging for display purposes."""
|
|
128
|
+
if isinstance(value, str) and value.startswith(_BOOL_TAG_PREFIX):
|
|
129
|
+
return value[len(_BOOL_TAG_PREFIX) :]
|
|
130
|
+
return str(value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _harmonic_mean(a: float, b: float) -> float:
|
|
134
|
+
if a <= 0 or b <= 0:
|
|
135
|
+
return 0.0
|
|
136
|
+
return 2 * a * b / (a + b)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def format_top(top: list[tuple[str, int]]) -> str:
|
|
140
|
+
"""`値(件数) / 値(件数)`, space-padded around `/` to reduce ambiguity
|
|
141
|
+
with slashes that appear inside values (dates, paths).
|
|
142
|
+
"""
|
|
143
|
+
return " / ".join(f"{value}({count})" for value, count in top)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- 入力の読み込み ---
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_csv_like(text: str, sep: str, header_row: int | None) -> Table:
|
|
150
|
+
reader = csv.reader(io.StringIO(text), delimiter=sep)
|
|
151
|
+
all_rows: list[tuple[object, ...]] = [tuple(row) for row in reader if row]
|
|
152
|
+
|
|
153
|
+
if header_row is None:
|
|
154
|
+
columns = [str(i) for i in range(len(all_rows[0]))] if all_rows else []
|
|
155
|
+
data_rows = all_rows
|
|
156
|
+
else:
|
|
157
|
+
columns = [str(c) for c in all_rows[header_row]] if header_row < len(all_rows) else []
|
|
158
|
+
data_rows = all_rows[header_row + 1 :]
|
|
159
|
+
|
|
160
|
+
return Table(columns=columns, rows=data_rows)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_json_records(text: str) -> Table:
|
|
164
|
+
"""Accepts either a JSON array of objects, or JSON Lines (one object per line).
|
|
165
|
+
|
|
166
|
+
Nested values (dict/list) are kept as JSON strings rather than flattened;
|
|
167
|
+
this is a flat-table profiler, not a schema explorer.
|
|
168
|
+
"""
|
|
169
|
+
stripped = text.strip()
|
|
170
|
+
if stripped.startswith("["):
|
|
171
|
+
records = json.loads(stripped)
|
|
172
|
+
else:
|
|
173
|
+
records = [json.loads(line) for line in stripped.splitlines() if line.strip()]
|
|
174
|
+
|
|
175
|
+
columns: list[str] = []
|
|
176
|
+
seen_columns: set[str] = set()
|
|
177
|
+
for record in records:
|
|
178
|
+
for key in record:
|
|
179
|
+
if key not in seen_columns:
|
|
180
|
+
seen_columns.add(key)
|
|
181
|
+
columns.append(key)
|
|
182
|
+
|
|
183
|
+
rows = []
|
|
184
|
+
for record in records:
|
|
185
|
+
row = []
|
|
186
|
+
for col in columns:
|
|
187
|
+
value = record.get(col)
|
|
188
|
+
if isinstance(value, (dict, list)):
|
|
189
|
+
value = json.dumps(value, ensure_ascii=False)
|
|
190
|
+
row.append(value)
|
|
191
|
+
rows.append(tuple(row))
|
|
192
|
+
|
|
193
|
+
return Table(columns=columns, rows=rows)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def read_excel_tables(path: Path, header_row: int | None) -> dict[str, Table]:
|
|
197
|
+
"""Read every sheet, read_only for speed (measured ~27% faster than the
|
|
198
|
+
default mode; see doc/profiler.md). Excel is far slower to read than CSV
|
|
199
|
+
of the same size regardless, so this only narrows the gap.
|
|
200
|
+
"""
|
|
201
|
+
import openpyxl
|
|
202
|
+
|
|
203
|
+
workbook = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
|
204
|
+
try:
|
|
205
|
+
result: dict[str, Table] = {}
|
|
206
|
+
for sheet_name in workbook.sheetnames:
|
|
207
|
+
worksheet = workbook[sheet_name]
|
|
208
|
+
all_rows = [row for row in worksheet.iter_rows(values_only=True) if row]
|
|
209
|
+
|
|
210
|
+
if header_row is None:
|
|
211
|
+
columns = [str(i) for i in range(len(all_rows[0]))] if all_rows else []
|
|
212
|
+
data_rows = all_rows
|
|
213
|
+
else:
|
|
214
|
+
columns = (
|
|
215
|
+
[str(c) if c is not None else "" for c in all_rows[header_row]]
|
|
216
|
+
if header_row < len(all_rows)
|
|
217
|
+
else []
|
|
218
|
+
)
|
|
219
|
+
data_rows = all_rows[header_row + 1 :]
|
|
220
|
+
|
|
221
|
+
result[sheet_name] = Table(columns=columns, rows=data_rows)
|
|
222
|
+
return result
|
|
223
|
+
finally:
|
|
224
|
+
workbook.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
SUPPORTED_SUFFIXES = frozenset({".csv", ".tsv", ".txt", ".xlsx", ".json", ".jsonl"})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def load_tables(path: Path, sep: str | None, header_row: int | None) -> dict[str, Table]:
|
|
231
|
+
"""Load every table in `path`. The dict key is the sheet name for Excel,
|
|
232
|
+
or "" for everything else (single-table formats).
|
|
233
|
+
"""
|
|
234
|
+
suffix = path.suffix.lower()
|
|
235
|
+
|
|
236
|
+
if suffix not in SUPPORTED_SUFFIXES:
|
|
237
|
+
raise ValueError(f"対応していない形式です: {path}")
|
|
238
|
+
|
|
239
|
+
if suffix == ".xlsx":
|
|
240
|
+
return read_excel_tables(path, header_row)
|
|
241
|
+
|
|
242
|
+
text = read_text_with_fallback(path)
|
|
243
|
+
|
|
244
|
+
if suffix in (".json", ".jsonl"):
|
|
245
|
+
return {"": read_json_records(text)}
|
|
246
|
+
|
|
247
|
+
resolved_sep = sep if sep is not None else ("\t" if suffix == ".tsv" else ",")
|
|
248
|
+
return {"": read_csv_like(text, resolved_sep, header_row)}
|