Skip to content

Commit 240ac28

Browse files
authored
Merge pull request #62 from ncdcdev/refactor/organize-excel-helpers
refactor: Organize Excel helper classes into modular structure
2 parents e493d24 + 5e54350 commit 240ac28

12 files changed

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

src/excel/pane_manager.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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: 上限超過の場合のみFalse、それ以外はTrue
71+
- validated_frozen_rows: 負の値は0に丸める、上限超過は0、それ以外は元の値
72+
"""
73+
# 負の値は無効として0に丸める
74+
if frozen_rows < 0:
75+
return (True, 0)
76+
if frozen_rows > max_limit:
77+
return (False, 0)
78+
return (True, frozen_rows)

0 commit comments

Comments
 (0)