Skip to content

Commit f4b36ed

Browse files
k-ibarakiclaude
andcommitted
refactor(excel): Organize helper classes into src/excel/ subdirectory
Move Excel helper classes to dedicated subdirectory for better organization: - ExcelRangeCalculator -> src/excel/range_calculator.py - ExcelMergedCellHandler -> src/excel/merged_cell_handler.py - ExcelPaneManager -> src/excel/pane_manager.py - ExcelStyleExtractor -> src/excel/style_extractor.py Add src/excel/__init__.py to export all helper classes. Update all imports in src/ and tests/ to use new module structure. Benefits: - Clearer separation of Excel-related utilities - Better project structure (src/excel/ for Excel helpers) - Maintains all existing functionality (142 tests pass) - Improved code organization without functional changes Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1883b73 commit f4b36ed

12 files changed

Lines changed: 1614 additions & 533 deletions

src/excel/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
Excel処理ヘルパーモジュール
3+
4+
SharePointExcelParserのリファクタリングで抽出されたヘルパークラス群
5+
"""
6+
7+
from src.excel.merged_cell_handler import ExcelMergedCellHandler
8+
from src.excel.pane_manager import ExcelPaneManager
9+
from src.excel.range_calculator import ExcelRangeCalculator
10+
from src.excel.style_extractor import ExcelStyleExtractor
11+
12+
__all__ = [
13+
"ExcelRangeCalculator",
14+
"ExcelMergedCellHandler",
15+
"ExcelPaneManager",
16+
"ExcelStyleExtractor",
17+
]

src/excel/merged_cell_handler.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""
2+
Excelマージセル処理ユーティリティ
3+
4+
マージセル情報のキャッシュ構築と値伝播を担当するヘルパークラス
5+
"""
6+
7+
from typing import Any
8+
9+
from openpyxl.utils import column_index_from_string, get_column_letter
10+
from openpyxl.utils.cell import coordinate_from_string
11+
12+
13+
class ExcelMergedCellHandler:
14+
"""マージセル情報の構築と管理(全て staticmethod)"""
15+
16+
@staticmethod
17+
def build_merged_cell_cache(
18+
sheet,
19+
effective_cell_range: str | None,
20+
value_serializer,
21+
) -> tuple[dict[str, str] | None, dict[str, Any] | None, list[dict[str, Any]]]:
22+
"""
23+
マージセル情報をキャッシュして返す(パフォーマンス最適化)
24+
- 「今回返す予定の範囲」を先に確定し、その範囲と交差する結合だけを部分展開する
25+
- アンカー値は左上→無ければ結合範囲内の実在セルのみから最小(row,col)を選ぶ
26+
27+
Args:
28+
sheet: openpyxl Worksheet
29+
effective_cell_range: 正規化・拡張済みのセル範囲(例: "A1:D10")
30+
Noneの場合はsheet.dimensionsを使用
31+
value_serializer: セル値をシリアライズする関数(例: parser._serialize_value)
32+
33+
Returns:
34+
(merged_cell_map, merged_anchor_value_map, merged_ranges)のタプル
35+
- merged_cell_map: セル座標 -> 結合範囲のマップ
36+
- merged_anchor_value_map: 結合範囲 -> アンカー値のマップ
37+
- merged_ranges: 結合範囲情報のリスト
38+
"""
39+
merged_cell_map: dict[str, str] | None = None
40+
merged_anchor_value_map: dict[str, Any] | None = None
41+
merged_ranges: list[dict[str, Any]] = []
42+
43+
# 今回返す予定の範囲(結合情報の部分展開に使用)
44+
# effective_cell_rangeがあればそれを使用、なければsheet.dimensionsを使用
45+
planned_range_for_merge = effective_cell_range or (
46+
str(sheet.dimensions) if sheet.dimensions else None
47+
)
48+
49+
if not sheet.merged_cells.ranges or not planned_range_for_merge:
50+
return (None, None, [])
51+
52+
# planned_range_for_merge から対象範囲の境界を計算
53+
if ":" in planned_range_for_merge:
54+
start_cell, end_cell = planned_range_for_merge.split(":", 1)
55+
else:
56+
start_cell = planned_range_for_merge
57+
end_cell = planned_range_for_merge
58+
59+
start_cell = start_cell.replace("$", "")
60+
end_cell = end_cell.replace("$", "")
61+
62+
start_col, start_row = coordinate_from_string(start_cell)
63+
end_col, end_row = coordinate_from_string(end_cell)
64+
65+
start_col_idx = column_index_from_string(start_col)
66+
end_col_idx = column_index_from_string(end_col)
67+
68+
target_min_row = min(start_row, end_row)
69+
target_max_row = max(start_row, end_row)
70+
target_min_col = min(start_col_idx, end_col_idx)
71+
target_max_col = max(start_col_idx, end_col_idx)
72+
73+
merged_cell_map = {}
74+
merged_anchor_value_map = {}
75+
76+
for merged_range in sheet.merged_cells.ranges:
77+
merged_range_str = str(merged_range)
78+
range_start = merged_range_str.split(":")[0]
79+
80+
merged_min_row = merged_range.min_row
81+
merged_max_row = merged_range.max_row
82+
merged_min_col = merged_range.min_col
83+
merged_max_col = merged_range.max_col
84+
85+
# 返す予定の範囲と交差しない結合は無視(部分展開)
86+
inter_min_row = max(merged_min_row, target_min_row)
87+
inter_max_row = min(merged_max_row, target_max_row)
88+
inter_min_col = max(merged_min_col, target_min_col)
89+
inter_max_col = min(merged_max_col, target_max_col)
90+
if inter_min_row > inter_max_row or inter_min_col > inter_max_col:
91+
continue
92+
93+
# アンカー値を決定(左上が空なら結合範囲内の実在セルだけ走査)
94+
anchor_coord = range_start
95+
anchor_value = value_serializer(sheet[range_start].value)
96+
97+
if anchor_value is None:
98+
anchor_coord, anchor_value = (
99+
ExcelMergedCellHandler._find_anchor_value_in_merge(
100+
sheet,
101+
merged_min_row,
102+
merged_max_row,
103+
merged_min_col,
104+
merged_max_col,
105+
value_serializer,
106+
)
107+
)
108+
109+
# セル座標 -> 結合範囲 のマップ(返す予定の範囲と交差する部分だけ展開)
110+
for row_idx in range(inter_min_row, inter_max_row + 1):
111+
for col_idx in range(inter_min_col, inter_max_col + 1):
112+
coord_str = f"{get_column_letter(col_idx)}{row_idx}"
113+
merged_cell_map[coord_str] = merged_range_str
114+
115+
# アンカー値を保存(結合セルの値埋め用)
116+
merged_anchor_value_map[merged_range_str] = anchor_value
117+
118+
# 結合範囲そのものを返す(結合セルがある時だけ返す)
119+
merged_ranges.append(
120+
{
121+
"range": merged_range_str,
122+
"anchor": {"coordinate": anchor_coord, "value": anchor_value},
123+
}
124+
)
125+
126+
if not merged_ranges:
127+
return (None, None, [])
128+
129+
return (merged_cell_map, merged_anchor_value_map, merged_ranges)
130+
131+
@staticmethod
132+
def _find_anchor_value_in_merge(
133+
sheet,
134+
merged_min_row: int,
135+
merged_max_row: int,
136+
merged_min_col: int,
137+
merged_max_col: int,
138+
value_serializer,
139+
) -> tuple[str, Any | None]:
140+
"""
141+
結合セル範囲内で最初の非空値を探す(左上が空の場合)
142+
143+
Args:
144+
sheet: openpyxl Worksheet
145+
merged_min_row: 結合範囲の最小行
146+
merged_max_row: 結合範囲の最大行
147+
merged_min_col: 結合範囲の最小列
148+
merged_max_col: 結合範囲の最大列
149+
value_serializer: セル値をシリアライズする関数
150+
151+
Returns:
152+
(anchor_coord, anchor_value)のタプル
153+
"""
154+
best_rc: tuple[int, int] | None = None
155+
best_val: Any | None = None
156+
157+
# 実在セル(sheet._cells)だけから、結合範囲内の最小(row,col)の値を選ぶ
158+
# 互換性のため_cellsの有無をチェックしてフォールバック
159+
# 注意: _cellsはopenpyxlのプライベート属性のため、将来のバージョンで変更される可能性があります。
160+
# その場合は公開APIを使用するフォールバックロジックが動作します。
161+
if hasattr(sheet, "_cells"):
162+
# プライベート属性を使った高速版
163+
for (r, c), cell_obj in sheet._cells.items():
164+
if (
165+
merged_min_row <= r <= merged_max_row
166+
and merged_min_col <= c <= merged_max_col
167+
):
168+
cell_value = value_serializer(cell_obj.value)
169+
if cell_value is not None:
170+
if best_rc is None or (r, c) < best_rc:
171+
best_rc = (r, c)
172+
best_val = cell_value
173+
else:
174+
# 公開APIを使ったフォールバック版
175+
for row_idx in range(merged_min_row, merged_max_row + 1):
176+
for col_idx in range(merged_min_col, merged_max_col + 1):
177+
coord = f"{get_column_letter(col_idx)}{row_idx}"
178+
cell = sheet[coord]
179+
cell_value = value_serializer(cell.value)
180+
if cell_value is not None:
181+
if best_rc is None or (row_idx, col_idx) < best_rc:
182+
best_rc = (row_idx, col_idx)
183+
best_val = cell_value
184+
185+
# アンカー座標とアンカー値を返す
186+
if best_rc is not None:
187+
r, c = best_rc
188+
anchor_coord = f"{get_column_letter(c)}{r}"
189+
return (anchor_coord, best_val)
190+
else:
191+
# 全てのセルが空の場合は最初のセルを返す
192+
anchor_coord = f"{get_column_letter(merged_min_col)}{merged_min_row}"
193+
return (anchor_coord, None)

src/excel/pane_manager.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
Excel固定行列(freeze_panes)管理ユーティリティ
3+
4+
固定行列情報の取得と変換を担当するヘルパークラス
5+
"""
6+
7+
import logging
8+
9+
from openpyxl.utils import get_column_letter
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class ExcelPaneManager:
15+
"""固定行列情報の取得と変換(全て staticmethod)"""
16+
17+
@staticmethod
18+
def get_frozen_panes(sheet) -> tuple[int, int]:
19+
"""
20+
シートのpane情報から固定行数・列数を返す(ySplit/xSplit使用)
21+
22+
sheet.freeze_panes(= pane.topLeftCell)はスクロール位置に依存するため、
23+
正確な固定行数・列数を得るには pane.ySplit / pane.xSplit を直接参照する。
24+
25+
Args:
26+
sheet: openpyxl Worksheet
27+
28+
Returns:
29+
(frozen_rows, frozen_cols)のタプル
30+
"""
31+
try:
32+
pane = sheet.sheet_view.pane
33+
if pane is None:
34+
return (0, 0)
35+
if pane.state not in ("frozen", "frozenSplit"):
36+
return (0, 0)
37+
frozen_rows = int(pane.ySplit) if pane.ySplit else 0
38+
frozen_cols = int(pane.xSplit) if pane.xSplit else 0
39+
return (frozen_rows, frozen_cols)
40+
except Exception as e:
41+
logger.warning(f"Failed to get frozen panes info: {e}")
42+
return (0, 0)
43+
44+
@staticmethod
45+
def format_freeze_panes(frozen_rows: int, frozen_cols: int) -> str:
46+
"""
47+
固定行数・列数からfreeze_panes文字列表現を生成
48+
49+
Args:
50+
frozen_rows: 固定行数
51+
frozen_cols: 固定列数
52+
53+
Returns:
54+
freeze_panes文字列表現(例: "B4")
55+
"""
56+
col_letter = get_column_letter(frozen_cols + 1)
57+
return f"{col_letter}{frozen_rows + 1}"
58+
59+
@staticmethod
60+
def validate_frozen_rows(frozen_rows: int, max_limit: int) -> tuple[bool, int]:
61+
"""
62+
固定行数をDoS対策上限で検証
63+
64+
Args:
65+
frozen_rows: 固定行数
66+
max_limit: 上限値
67+
68+
Returns:
69+
(is_valid, validated_frozen_rows)のタプル
70+
- is_valid: 上限以内ならTrue、超過ならFalse
71+
- validated_frozen_rows: 超過時は0、それ以外は元の値
72+
"""
73+
if frozen_rows > max_limit:
74+
return (False, 0)
75+
return (True, frozen_rows)

0 commit comments

Comments
 (0)