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,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)}