-
Notifications
You must be signed in to change notification settings - Fork 1
refactor: Organize Excel helper classes into modular structure #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
f4b36ed
refactor(excel): Organize helper classes into src/excel/ subdirectory
k-ibaraki 37b8f88
refactor(tests): Organize Excel tests into tests/excel/ subdirectory
k-ibaraki 6b3f2bc
Merge branch 'main' into refactor/organize-excel-helpers
k-ibaraki ea5ceeb
fix: Address code review feedback
k-ibaraki 5e54350
docs: Fix docstring accuracy and improve code documentation
k-ibaraki File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| """ | ||
| Excel処理ヘルパーモジュール | ||
|
|
||
| SharePointExcelParserのリファクタリングで抽出されたヘルパークラス群 | ||
| """ | ||
|
|
||
| from src.excel.merged_cell_handler import ExcelMergedCellHandler | ||
| from src.excel.pane_manager import ExcelPaneManager | ||
| from src.excel.range_calculator import ExcelRangeCalculator | ||
| from src.excel.style_extractor import ExcelStyleExtractor | ||
|
|
||
| __all__ = [ | ||
| "ExcelRangeCalculator", | ||
| "ExcelMergedCellHandler", | ||
| "ExcelPaneManager", | ||
| "ExcelStyleExtractor", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| """ | ||
| Excelマージセル処理ユーティリティ | ||
|
|
||
| マージセル情報のキャッシュ構築と値伝播を担当するヘルパークラス | ||
| """ | ||
|
|
||
| from typing import Any | ||
|
|
||
| from openpyxl.utils import column_index_from_string, get_column_letter | ||
| from openpyxl.utils.cell import coordinate_from_string | ||
|
|
||
|
|
||
| class ExcelMergedCellHandler: | ||
| """マージセル情報の構築と管理(全て staticmethod)""" | ||
|
|
||
| @staticmethod | ||
| def build_merged_cell_cache( | ||
| sheet, | ||
| effective_cell_range: str | None, | ||
| value_serializer, | ||
| ) -> tuple[dict[str, str] | None, dict[str, Any] | None, list[dict[str, Any]]]: | ||
| """ | ||
| マージセル情報をキャッシュして返す(パフォーマンス最適化) | ||
| - 「今回返す予定の範囲」を先に確定し、その範囲と交差する結合だけを部分展開する | ||
| - アンカー値は左上→無ければ結合範囲内の実在セルのみから最小(row,col)を選ぶ | ||
|
|
||
| Args: | ||
| sheet: openpyxl Worksheet | ||
| effective_cell_range: 正規化・拡張済みのセル範囲(例: "A1:D10") | ||
| Noneの場合はsheet.dimensionsを使用 | ||
| value_serializer: セル値をシリアライズする関数(例: parser._serialize_value) | ||
|
|
||
| Returns: | ||
| (merged_cell_map, merged_anchor_value_map, merged_ranges)のタプル | ||
| - merged_cell_map: セル座標 -> 結合範囲のマップ | ||
| - merged_anchor_value_map: 結合範囲 -> アンカー値のマップ | ||
| - merged_ranges: 結合範囲情報のリスト | ||
| """ | ||
| merged_cell_map: dict[str, str] | None = None | ||
| merged_anchor_value_map: dict[str, Any] | None = None | ||
| merged_ranges: list[dict[str, Any]] = [] | ||
|
|
||
| # 今回返す予定の範囲(結合情報の部分展開に使用) | ||
| # effective_cell_rangeがあればそれを使用、なければsheet.dimensionsを使用 | ||
| planned_range_for_merge = effective_cell_range or ( | ||
| str(sheet.dimensions) if sheet.dimensions else None | ||
| ) | ||
|
|
||
| if not sheet.merged_cells.ranges or not planned_range_for_merge: | ||
| return (None, None, []) | ||
|
|
||
| # planned_range_for_merge から対象範囲の境界を計算 | ||
| if ":" in planned_range_for_merge: | ||
| start_cell, end_cell = planned_range_for_merge.split(":", 1) | ||
| else: | ||
| start_cell = planned_range_for_merge | ||
| end_cell = planned_range_for_merge | ||
|
|
||
| start_cell = start_cell.replace("$", "") | ||
| end_cell = end_cell.replace("$", "") | ||
|
|
||
| start_col, start_row = coordinate_from_string(start_cell) | ||
| end_col, end_row = coordinate_from_string(end_cell) | ||
|
|
||
| start_col_idx = column_index_from_string(start_col) | ||
| end_col_idx = column_index_from_string(end_col) | ||
|
|
||
| target_min_row = min(start_row, end_row) | ||
| target_max_row = max(start_row, end_row) | ||
| target_min_col = min(start_col_idx, end_col_idx) | ||
| target_max_col = max(start_col_idx, end_col_idx) | ||
|
|
||
| merged_cell_map = {} | ||
| merged_anchor_value_map = {} | ||
|
|
||
| for merged_range in sheet.merged_cells.ranges: | ||
| merged_range_str = str(merged_range) | ||
| range_start = merged_range_str.split(":")[0] | ||
|
|
||
| merged_min_row = merged_range.min_row | ||
| merged_max_row = merged_range.max_row | ||
| merged_min_col = merged_range.min_col | ||
| merged_max_col = merged_range.max_col | ||
|
|
||
| # 返す予定の範囲と交差しない結合は無視(部分展開) | ||
| inter_min_row = max(merged_min_row, target_min_row) | ||
| inter_max_row = min(merged_max_row, target_max_row) | ||
| inter_min_col = max(merged_min_col, target_min_col) | ||
| inter_max_col = min(merged_max_col, target_max_col) | ||
| if inter_min_row > inter_max_row or inter_min_col > inter_max_col: | ||
| continue | ||
|
|
||
| # アンカー値を決定(左上が空なら結合範囲内の実在セルだけ走査) | ||
| anchor_coord = range_start | ||
| anchor_value = value_serializer(sheet[range_start].value) | ||
|
|
||
| if anchor_value is None: | ||
| anchor_coord, anchor_value = ( | ||
| ExcelMergedCellHandler._find_anchor_value_in_merge( | ||
| sheet, | ||
| merged_min_row, | ||
| merged_max_row, | ||
| merged_min_col, | ||
| merged_max_col, | ||
| value_serializer, | ||
| ) | ||
| ) | ||
|
|
||
| # セル座標 -> 結合範囲 のマップ(返す予定の範囲と交差する部分だけ展開) | ||
| for row_idx in range(inter_min_row, inter_max_row + 1): | ||
| for col_idx in range(inter_min_col, inter_max_col + 1): | ||
| coord_str = f"{get_column_letter(col_idx)}{row_idx}" | ||
| merged_cell_map[coord_str] = merged_range_str | ||
|
|
||
| # アンカー値を保存(結合セルの値埋め用) | ||
| merged_anchor_value_map[merged_range_str] = anchor_value | ||
|
|
||
| # 結合範囲そのものを返す(結合セルがある時だけ返す) | ||
| merged_ranges.append( | ||
| { | ||
| "range": merged_range_str, | ||
| "anchor": {"coordinate": anchor_coord, "value": anchor_value}, | ||
| } | ||
| ) | ||
|
|
||
| if not merged_ranges: | ||
| return (None, None, []) | ||
|
|
||
| return (merged_cell_map, merged_anchor_value_map, merged_ranges) | ||
|
|
||
| @staticmethod | ||
| def _find_anchor_value_in_merge( | ||
| sheet, | ||
| merged_min_row: int, | ||
| merged_max_row: int, | ||
| merged_min_col: int, | ||
| merged_max_col: int, | ||
| value_serializer, | ||
| ) -> tuple[str, Any | None]: | ||
| """ | ||
| 結合セル範囲内で最初の非空値を探す(左上が空の場合) | ||
|
|
||
| Args: | ||
| sheet: openpyxl Worksheet | ||
| merged_min_row: 結合範囲の最小行 | ||
| merged_max_row: 結合範囲の最大行 | ||
| merged_min_col: 結合範囲の最小列 | ||
| merged_max_col: 結合範囲の最大列 | ||
| value_serializer: セル値をシリアライズする関数 | ||
|
|
||
| Returns: | ||
| (anchor_coord, anchor_value)のタプル | ||
| """ | ||
| best_rc: tuple[int, int] | None = None | ||
| best_val: Any | None = None | ||
|
|
||
| # 実在セル(sheet._cells)だけから、結合範囲内の最小(row,col)の値を選ぶ | ||
| # 互換性のため_cellsの有無をチェックしてフォールバック | ||
| # 注意: _cellsはopenpyxlのプライベート属性のため、将来のバージョンで変更される可能性があります。 | ||
| # その場合は公開APIを使用するフォールバックロジックが動作します。 | ||
| if hasattr(sheet, "_cells"): | ||
| # プライベート属性を使った高速版 | ||
| for (r, c), cell_obj in sheet._cells.items(): | ||
| if ( | ||
| merged_min_row <= r <= merged_max_row | ||
| and merged_min_col <= c <= merged_max_col | ||
| ): | ||
| cell_value = value_serializer(cell_obj.value) | ||
| if cell_value is not None: | ||
| if best_rc is None or (r, c) < best_rc: | ||
| best_rc = (r, c) | ||
| best_val = cell_value | ||
| else: | ||
| # 公開APIを使ったフォールバック版 | ||
| for row_idx in range(merged_min_row, merged_max_row + 1): | ||
| for col_idx in range(merged_min_col, merged_max_col + 1): | ||
| coord = f"{get_column_letter(col_idx)}{row_idx}" | ||
| cell = sheet[coord] | ||
| cell_value = value_serializer(cell.value) | ||
| if cell_value is not None: | ||
| if best_rc is None or (row_idx, col_idx) < best_rc: | ||
| best_rc = (row_idx, col_idx) | ||
| best_val = cell_value | ||
|
|
||
| # アンカー座標とアンカー値を返す | ||
| if best_rc is not None: | ||
| r, c = best_rc | ||
| anchor_coord = f"{get_column_letter(c)}{r}" | ||
| return (anchor_coord, best_val) | ||
| else: | ||
| # 全てのセルが空の場合は最初のセルを返す | ||
| anchor_coord = f"{get_column_letter(merged_min_col)}{merged_min_row}" | ||
| return (anchor_coord, None) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| """ | ||
| Excel固定行列(freeze_panes)管理ユーティリティ | ||
|
|
||
| 固定行列情報の取得と変換を担当するヘルパークラス | ||
| """ | ||
|
|
||
| import logging | ||
|
|
||
| from openpyxl.utils import get_column_letter | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class ExcelPaneManager: | ||
| """固定行列情報の取得と変換(全て staticmethod)""" | ||
|
|
||
| @staticmethod | ||
| def get_frozen_panes(sheet) -> tuple[int, int]: | ||
| """ | ||
| シートのpane情報から固定行数・列数を返す(ySplit/xSplit使用) | ||
|
|
||
| sheet.freeze_panes(= pane.topLeftCell)はスクロール位置に依存するため、 | ||
| 正確な固定行数・列数を得るには pane.ySplit / pane.xSplit を直接参照する。 | ||
|
|
||
| Args: | ||
| sheet: openpyxl Worksheet | ||
|
|
||
| Returns: | ||
| (frozen_rows, frozen_cols)のタプル | ||
| """ | ||
| try: | ||
| pane = sheet.sheet_view.pane | ||
| if pane is None: | ||
| return (0, 0) | ||
| if pane.state not in ("frozen", "frozenSplit"): | ||
| return (0, 0) | ||
| frozen_rows = int(pane.ySplit) if pane.ySplit else 0 | ||
| frozen_cols = int(pane.xSplit) if pane.xSplit else 0 | ||
| return (frozen_rows, frozen_cols) | ||
| except Exception as e: | ||
| logger.warning(f"Failed to get frozen panes info: {e}") | ||
| return (0, 0) | ||
|
|
||
| @staticmethod | ||
| def format_freeze_panes(frozen_rows: int, frozen_cols: int) -> str: | ||
| """ | ||
| 固定行数・列数からfreeze_panes文字列表現を生成 | ||
|
|
||
| Args: | ||
| frozen_rows: 固定行数 | ||
| frozen_cols: 固定列数 | ||
|
|
||
| Returns: | ||
| freeze_panes文字列表現(例: "B4") | ||
| """ | ||
| col_letter = get_column_letter(frozen_cols + 1) | ||
| return f"{col_letter}{frozen_rows + 1}" | ||
|
|
||
| @staticmethod | ||
| def validate_frozen_rows(frozen_rows: int, max_limit: int) -> tuple[bool, int]: | ||
| """ | ||
| 固定行数をDoS対策上限で検証 | ||
|
|
||
| Args: | ||
| frozen_rows: 固定行数 | ||
| max_limit: 上限値 | ||
|
|
||
| Returns: | ||
| (is_valid, validated_frozen_rows)のタプル | ||
| - is_valid: 上限以内ならTrue、超過ならFalse | ||
| - validated_frozen_rows: 超過時は0、それ以外は元の値 | ||
| """ | ||
| if frozen_rows > max_limit: | ||
| return (False, 0) | ||
| return (True, frozen_rows) | ||
|
k-ibaraki marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.