From 8e0050a6a4eb9c7cfb45596472adf6cbbe158746 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 16:22:15 +0900 Subject: [PATCH 01/14] =?UTF-8?q?codecov=E3=83=90=E3=83=83=E3=82=B8?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 2 +- README.md | 4 +++- docs/README.en.md | 2 +- docs/README.ja.md | 5 +++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.ja.md b/README.ja.md index 74958de..2cb0d0c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,6 @@ # ExStruct — Excel 構造化抽出エンジン -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) ![ExStruct Image](/docs/assets/icon.webp) diff --git a/README.md b/README.md index 1284127..49b362e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # ExStruct — Excel Structured Extraction Engine -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) + + ![ExStruct Image](/docs/assets/icon.webp) diff --git a/docs/README.en.md b/docs/README.en.md index 1c07081..46c214b 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -1,6 +1,6 @@ # ExStruct — Excel Structured Extraction Engine -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) ![ExStruct Image](assets/icon.webp) diff --git a/docs/README.ja.md b/docs/README.ja.md index 8b52db3..8ef6303 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -1,6 +1,6 @@ # ExStruct — Excel 構造化抽出エンジン -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) ![ExStruct Image](assets/icon.webp) @@ -341,7 +341,7 @@ flowchart TD ということが明確に示されています。 -その他の本ライブラリを使ったLLM推論サンプルは以下のディレクトリにあります。 +その他の本ライブラリを使った LLM 推論サンプルは以下のディレクトリにあります。 - [Basic Excel](sample/basic/) - [Flowchart](sample/flowchart/) @@ -371,6 +371,7 @@ ExStruct は主に **ライブラリ** として利用される想定で、サ - 企業利用ではフォークや内部改修が前提です 次のようなチームに適しています。 + - ブラックボックス化されたツールではなく、透明性が必要 - 必要に応じて内部フォークを保守できる From 384244c96d17b3ebe5b8d2089bdac9923e5fa23b Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 16:23:51 +0900 Subject: [PATCH 02/14] tasks init --- docs/agents/TASKS.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 66cc050..d268dc5 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -1,8 +1,3 @@ # Task List 未完了タスクは [ ]、完了タスクは [x] - -- [x] src/exstruct/render/__init__.py の主要分岐と例外経路を洗い出す(_require_excel_app/_require_pdfium/export_pdf/export_sheet_images/_sanitize_sheet_filename) -- [x] xlwings・pypdfium2 をモックして export_pdf/export_sheet_images の成功/失敗ケースを単体テスト化する -- [x] 依存不足・予期例外のエラーメッセージ/例外型を検証するテストを追加する -- [x] シート名のサニタイズ規則と出力ファイル名生成のテストを追加する From 72d4fa8a54caaf795bb7355bd0a2a6e66c436343 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 16:24:23 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feature=20spec=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/FEATURE_SPEC.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/agents/FEATURE_SPEC.md b/docs/agents/FEATURE_SPEC.md index 12955e5..f512aa7 100644 --- a/docs/agents/FEATURE_SPEC.md +++ b/docs/agents/FEATURE_SPEC.md @@ -11,12 +11,6 @@ - SmartArtは基本はShapeのフィールドを持ちつつ、Nodeの情報を再帰的に持つようにする - rootノードとそれ以外のノードでクラスを分ける -## リファクタリング案 - -- リソース取得の冗長性 - - 事象: 印刷範囲取得が openpyxl→COM のようにロジックがファイル内に分散。似たパターンが他にもある。 - - 対策案: 抽出パイプラインをステップ化し、各ステップ(cells, tables, shapes, charts, print_areas)の実装をモジュール単位で揃える。パイプライン定義を 1 か所にまとめるとモード追加や切替が容易になる。 - ## 今後のオプション(検討メモ) - 表検出スコアリングの閾値を CLI/環境変数で調整可能にする。 From 65c4d1814caf5e62b0f3318efe0dec155429d1fe Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 16:52:22 +0900 Subject: [PATCH 04/14] =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=BF=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AB=E3=81=AE=E6=9B=B4=E6=96=B0:=20Shape,=20Arrow?= =?UTF-8?q?,=20SmartArt=E3=82=92=E5=88=86=E9=9B=A2=E3=81=97=E3=80=81SmartA?= =?UTF-8?q?rtNode=E3=81=AE=E3=83=8D=E3=82=B9=E3=83=88=E6=A7=8B=E9=80=A0?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- docs/agents/DATA_MODEL.md | 41 ++++++++++++++---- docs/agents/TASKS.md | 33 ++++++++++++++- src/exstruct/core/modeling.py | 13 +++++- src/exstruct/core/pipeline.py | 4 +- src/exstruct/core/shapes.py | 75 +++++++++++++++++++++------------ src/exstruct/io/__init__.py | 26 ++++++++++-- src/exstruct/models/__init__.py | 40 ++++++++++++++++-- 8 files changed, 185 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index 156eec4..9a7167d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ output.json ruff_report.txt mypy_report.txt coverage.xml -htmlcov/ \ No newline at end of file +htmlcov/ +sample_smartart.py \ No newline at end of file diff --git a/docs/agents/DATA_MODEL.md b/docs/agents/DATA_MODEL.md index 67a9c49..389d7e6 100644 --- a/docs/agents/DATA_MODEL.md +++ b/docs/agents/DATA_MODEL.md @@ -1,6 +1,6 @@ # ExStruct データモデル仕様 -**Version**: 0.10 +**Version**: 0.13 **Status**: Authoritative — 本ドキュメントは ExStruct が返す全モデルの唯一の正準ソースです。 core / io / integrate は必ずこの仕様に従うこと。モデルは **pydantic v2** で実装します。 @@ -13,32 +13,54 @@ ExStruct は Excel ワークブックを LLM が扱いやすい **意味構造 --- -# 2. Shape Model +# 2. Shape / Arrow / SmartArt Model + +出力の `shapes` は下記 3 モデルのユニオンです。`kind` で判別します。 ```jsonc -Shape { - id: int | null // sheet 内での通番 id(線・矢印は null の場合あり) +BaseShape { + id: int | null // sheet 内の通番 id(線/矢印は null の場合あり) text: str l: int // left (px) t: int // top (px) w: int | null // width (px) h: int | null // height(px) - type: str | null // MSO 図形タイプのラベル + type: str | null // MSO 図形タイプラベル rotation: float | null +} + +Shape extends BaseShape { + kind: "shape" +} + +Arrow extends BaseShape { + kind: "arrow" begin_arrow_style: int | null end_arrow_style: int | null begin_id: int | null // コネクタ始点の接続先 Shape.id end_id: int | null // コネクタ終点の接続先 Shape.id direction: "E"|"SE"|"S"|"SW"|"W"|"NW"|"N"|"NE" | null } + +SmartArtNode { + text: str + level: int + children: [SmartArtNode] +} + +SmartArt extends BaseShape { + kind: "smartart" + layout_name: str + roots: [SmartArtNode] +} ``` 補足: - `direction` は線や矢印の向きを 8 方位に正規化したもの。 - 矢印スタイルは Excel の enum に対応。 -- `begin_id` / `end_id` は、コネクタが接続している図形の `id`(Excel の `ConnectorFormat.BeginConnectedShape` / `EndConnectedShape` に対応)。 -- 線や矢印の Shape では `id` が null になる場合があります。 +- `begin_id` / `end_id` は、コネクタが接続している図形の `id` に対応(`ConnectorFormat.BeginConnectedShape` / `EndConnectedShape`)。 +- `SmartArtNode` はネスト構造で表現し、`roots` がツリーの根。 --- @@ -114,7 +136,7 @@ PrintAreaView { book_name: str sheet_name: str area: PrintArea - shapes: [Shape] + shapes: [Shape | Arrow | SmartArt] charts: [Chart] rows: [CellRow] // 範囲に交差する行のみ、空列は落とす table_candidates: [str] // 範囲内に収まるテーブル候補 @@ -132,7 +154,7 @@ PrintAreaView { ```jsonc SheetData { rows: [CellRow] - shapes: [Shape] + shapes: [Shape | Arrow | SmartArt] charts: [Chart] table_candidates: [str] print_areas: [PrintArea] @@ -204,3 +226,4 @@ WorkbookData { - 0.10: Shape に `id` を追加し、コネクタの接続元/接続先を `id` 参照に変更し、`name` をペイロードから除去。 - 0.11: コネクタのフィールド名を `begin_id` / `end_id` にリネーム。 - 0.12: SheetData に背景色情報を格納する`colors_map`を追加。 +- 0.13: Shape を `Shape` / `Arrow` / `SmartArt` に分離し、`SmartArtNode` のネスト構造を追加。 diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index d268dc5..c36719c 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -1,3 +1,34 @@ # Task List -未完了タスクは [ ]、完了タスクは [x] +## 1. 既存実装の修正(モデル分離の影響対応) + +- [ ] `src/exstruct/io/__init__.py` の `_filter_shapes_to_area` が `list[Shape | Arrow | SmartArt]` を受け取れるように型と処理を調整する +- [ ] `src/exstruct/core/shapes.py` のコネクタ判定を `Arrow` 前提に変更する(`begin_arrow_style` / `end_arrow_style` などは `Arrow` のみ参照) +- [ ] `src/exstruct/core/shapes.py` の接続 ID 参照を `Arrow` に限定し、`Shape` からの誤参照を除去する +- [ ] `PrintAreaView` 側の `shapes` フィルタで `SmartArt` を落とさないことを確認する + +## 2. SmartArt 取得機能の実装方針 + +- [ ] `shape.HasSmartArt` を条件に SmartArt を抽出する +- [ ] `SmartArt.Layout.Name` を `SmartArt.layout_name` に格納する +- [ ] `SmartArt.AllNodes` を走査し、`level` と `text` を収集する +- [ ] ノード配列から `SmartArtNode` のツリー(`roots`)を構築する(`level` を使ったスタック組み立て) +- [ ] `SmartArt` は `BaseShape` 相当の位置/サイズ/回転/テキストを併せて格納する + +## 3. 実装箇所の整理 + +- [ ] `src/exstruct/core/shapes.py` に SmartArt 抽出用の関数を追加する(1 関数=1 責務を遵守) +- [ ] `src/exstruct/core/shapes.py` のメイン抽出処理で `Shape` / `Arrow` / `SmartArt` に振り分ける +- [ ] `src/exstruct/io/__init__.py` で `Shape | Arrow | SmartArt` のシリアライズ挙動が崩れないことを確認する + +## 4. 動作確認 + +- [ ] 既存の shape / connector 抽出が壊れていないことを確認する +- [ ] SmartArt が含まれるブックで `SmartArt.roots` が期待どおりに出力されることを確認する + +## 5. テストケース(カバレッジ維持) + +- [ ] `SmartArt` の `roots` がネスト構造でシリアライズされることを確認する +- [ ] `Arrow` のみが `begin_id` / `end_id` を持ち、`Shape` では参照されないことを確認する +- [ ] `_filter_shapes_to_area` が `Shape | Arrow | SmartArt` を受け取り、SmartArt も対象に含めることを確認する +- [ ] `kind` による判別が想定どおり動くことを確認する diff --git a/src/exstruct/core/modeling.py b/src/exstruct/core/modeling.py index 475e036..2b312e8 100644 --- a/src/exstruct/core/modeling.py +++ b/src/exstruct/core/modeling.py @@ -2,7 +2,16 @@ from dataclasses import dataclass -from ..models import CellRow, Chart, PrintArea, Shape, SheetData, WorkbookData +from ..models import ( + Arrow, + CellRow, + Chart, + PrintArea, + Shape, + SheetData, + SmartArt, + WorkbookData, +) @dataclass(frozen=True) @@ -20,7 +29,7 @@ class SheetRawData: """ rows: list[CellRow] - shapes: list[Shape] + shapes: list[Shape | Arrow | SmartArt] charts: list[Chart] table_candidates: list[str] print_areas: list[PrintArea] diff --git a/src/exstruct/core/pipeline.py b/src/exstruct/core/pipeline.py index 3258da9..9dfcc04 100644 --- a/src/exstruct/core/pipeline.py +++ b/src/exstruct/core/pipeline.py @@ -10,7 +10,7 @@ import xlwings as xw from ..errors import FallbackReason -from ..models import CellRow, Chart, PrintArea, Shape, WorkbookData +from ..models import Arrow, CellRow, Chart, PrintArea, Shape, SmartArt, WorkbookData from .backends.com_backend import ComBackend from .backends.openpyxl_backend import OpenpyxlBackend from .cells import WorkbookColorsMap, detect_tables @@ -23,7 +23,7 @@ ExtractionMode = Literal["light", "standard", "verbose"] CellData = dict[str, list[CellRow]] PrintAreaData = dict[str, list[PrintArea]] -ShapeData = dict[str, list[Shape]] +ShapeData = dict[str, list[Shape | Arrow | SmartArt]] ChartData = dict[str, list[Chart]] logger = logging.getLogger(__name__) diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index 02b2257..931ae45 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -2,12 +2,12 @@ from collections.abc import Iterator import math -from typing import SupportsInt, cast +from typing import Literal, SupportsInt, cast import xlwings as xw from xlwings import Book -from ..models import Shape +from ..models import Arrow, Shape, SmartArt from ..models.maps import MSO_AUTO_SHAPE_TYPE_MAP, MSO_SHAPE_TYPE_MAP @@ -16,11 +16,13 @@ def compute_line_angle_deg(w: float, h: float) -> float: return math.degrees(math.atan2(h, w)) % 360.0 -def angle_to_compass(angle: float) -> str: +def angle_to_compass( + angle: float, +) -> Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"]: """Convert angle to 8-point compass direction (0deg=E, 45deg=NE, 90deg=N, etc).""" dirs = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"] idx = int(((angle + 22.5) % 360) // 45) - return dirs[idx] + return cast(Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"], dirs[idx]) def coord_to_cell_by_edges( @@ -110,14 +112,14 @@ def _should_include_shape( def get_shapes_with_position( # noqa: C901 workbook: Book, mode: str = "standard" -) -> dict[str, list[Shape]]: - """Scan shapes in a workbook and return per-sheet Shape lists with position info.""" - shape_data: dict[str, list[Shape]] = {} +) -> dict[str, list[Shape | Arrow | SmartArt]]: + """Scan shapes in a workbook and return per-sheet shape lists with position info.""" + shape_data: dict[str, list[Shape | Arrow | SmartArt]] = {} for sheet in workbook.sheets: - shapes: list[Shape] = [] + shapes: list[Shape | Arrow | SmartArt] = [] excel_names: list[tuple[str, int]] = [] node_index = 0 - pending_connections: list[tuple[Shape, str | None, str | None]] = [] + pending_connections: list[tuple[Arrow, str | None, str | None]] = [] for root in sheet.shapes: for shp in iter_shapes_recursive(root): try: @@ -179,7 +181,8 @@ def get_shapes_with_position( # noqa: C901 ): is_relationship_geom = True if shape_type_str and ( - "Connector" in shape_type_str or shape_type_str in ("Line", "ConnectLine") + "Connector" in shape_type_str + or shape_type_str in ("Line", "ConnectLine") ): is_relationship_geom = True if shape_name and ("Connector" in shape_name or "Line" in shape_name): @@ -192,19 +195,34 @@ def get_shapes_with_position( # noqa: C901 excel_name = shape_name if isinstance(shape_name, str) else None - shape_obj = Shape( - id=shape_id, - text=text, - l=int(shp.left), - t=int(shp.top), - w=int(shp.width) - if mode == "verbose" or shape_type_str == "Group" - else None, - h=int(shp.height) - if mode == "verbose" or shape_type_str == "Group" - else None, - type=type_label, - ) + if is_relationship_geom: + shape_obj: Shape | Arrow | SmartArt = Arrow( + id=shape_id, + text=text, + l=int(shp.left), + t=int(shp.top), + w=int(shp.width) + if mode == "verbose" or shape_type_str == "Group" + else None, + h=int(shp.height) + if mode == "verbose" or shape_type_str == "Group" + else None, + type=type_label, + ) + else: + shape_obj = Shape( + id=shape_id, + text=text, + l=int(shp.left), + t=int(shp.top), + w=int(shp.width) + if mode == "verbose" or shape_type_str == "Group" + else None, + h=int(shp.height) + if mode == "verbose" or shape_type_str == "Group" + else None, + type=type_label, + ) if excel_name: if shape_id is not None: excel_names.append((excel_name, shape_id)) @@ -215,7 +233,8 @@ def get_shapes_with_position( # noqa: C901 angle = compute_line_angle_deg( float(shp.width), float(shp.height) ) - shape_obj.direction = angle_to_compass(angle) # type: ignore + if isinstance(shape_obj, Arrow): + shape_obj.direction = angle_to_compass(angle) try: rot = float(shp.api.Rotation) if abs(rot) > 1e-6: @@ -225,8 +244,9 @@ def get_shapes_with_position( # noqa: C901 try: begin_style = int(shp.api.Line.BeginArrowheadStyle) end_style = int(shp.api.Line.EndArrowheadStyle) - shape_obj.begin_arrow_style = begin_style - shape_obj.end_arrow_style = end_style + if isinstance(shape_obj, Arrow): + shape_obj.begin_arrow_style = begin_style + shape_obj.end_arrow_style = end_style except Exception: pass # Connector begin/end connected shapes (if this shape is a connector). @@ -262,7 +282,8 @@ def get_shapes_with_position( # noqa: C901 pass except Exception: pass - pending_connections.append((shape_obj, begin_name, end_name)) + if isinstance(shape_obj, Arrow): + pending_connections.append((shape_obj, begin_name, end_name)) shapes.append(shape_obj) if pending_connections: name_to_id = {name: sid for name, sid in excel_names} diff --git a/src/exstruct/io/__init__.py b/src/exstruct/io/__init__.py index c8c1201..e2ad37a 100644 --- a/src/exstruct/io/__init__.py +++ b/src/exstruct/io/__init__.py @@ -7,7 +7,16 @@ from ..core.ranges import RangeBounds, parse_range_zero_based from ..errors import OutputError, SerializationError -from ..models import CellRow, Chart, PrintArea, PrintAreaView, Shape, WorkbookData +from ..models import ( + Arrow, + CellRow, + Chart, + PrintArea, + PrintAreaView, + Shape, + SmartArt, + WorkbookData, +) from ..models.types import JsonStructure from .serialize import ( _FORMAT_HINTS, @@ -34,7 +43,14 @@ def dict_without_empty_values(obj: object) -> JsonStructure: ] if isinstance( obj, - WorkbookData | CellRow | Chart | PrintArea | PrintAreaView | Shape, + WorkbookData + | CellRow + | Chart + | PrintArea + | PrintAreaView + | Shape + | Arrow + | SmartArt, ): return dict_without_empty_values(obj.model_dump(exclude_none=True)) return cast(JsonStructure, obj) @@ -161,9 +177,11 @@ def _rects_overlap(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) - return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3]) -def _filter_shapes_to_area(shapes: list[Shape], area: PrintArea) -> list[Shape]: +def _filter_shapes_to_area( + shapes: list[Shape | Arrow | SmartArt], area: PrintArea +) -> list[Shape | Arrow | SmartArt]: area_rect = _area_to_px_rect(area) - filtered: list[Shape] = [] + filtered: list[Shape | Arrow | SmartArt] = [] for shp in shapes: if shp.w is None or shp.h is None: # Fallback: treat shape as a point if size is unknown (standard mode). diff --git a/src/exstruct/models/__init__.py b/src/exstruct/models/__init__.py index 65cfb30..8db5df5 100644 --- a/src/exstruct/models/__init__.py +++ b/src/exstruct/models/__init__.py @@ -8,8 +8,8 @@ from pydantic import BaseModel, Field -class Shape(BaseModel): - """Shape metadata (position, size, text, and styling).""" +class BaseShape(BaseModel): + """Common shape metadata (position, size, text, and styling).""" id: int | None = Field( default=None, @@ -24,6 +24,18 @@ class Shape(BaseModel): rotation: float | None = Field( default=None, description="Rotation angle in degrees." ) + + +class Shape(BaseShape): + """Normal shape metadata.""" + + kind: Literal["shape"] = Field(default="shape", description="Shape kind.") + + +class Arrow(BaseShape): + """Connector shape metadata.""" + + kind: Literal["arrow"] = Field(default="arrow", description="Shape kind.") begin_arrow_style: int | None = Field( default=None, description="Arrow style enum for the start of a connector." ) @@ -47,6 +59,26 @@ class Shape(BaseModel): ) +class SmartArtNode(BaseModel): + """Node of SmartArt hierarchy.""" + + text: str = Field(description="Visible text for the node.") + level: int = Field(description="Node depth level.") + children: list[SmartArtNode] = Field( + default_factory=list, description="Child nodes." + ) + + +class SmartArt(BaseShape): + """SmartArt shape metadata with nested nodes.""" + + kind: Literal["smartart"] = Field(default="smartart", description="Shape kind.") + layout_name: str = Field(description="SmartArt layout name.") + roots: list[SmartArtNode] = Field( + default_factory=list, description="Root nodes of SmartArt tree." + ) + + class CellRow(BaseModel): """A single row of cells with optional hyperlinks.""" @@ -109,7 +141,7 @@ class SheetData(BaseModel): rows: list[CellRow] = Field( default_factory=list, description="Extracted rows with cell values and links." ) - shapes: list[Shape] = Field( + shapes: list[Shape | Arrow | SmartArt] = Field( default_factory=list, description="Shapes detected on the sheet." ) charts: list[Chart] = Field( @@ -267,7 +299,7 @@ class PrintAreaView(BaseModel): book_name: str = Field(description="Workbook name owning the area.") sheet_name: str = Field(description="Sheet name owning the area.") area: PrintArea = Field(description="Print area bounds.") - shapes: list[Shape] = Field( + shapes: list[Shape | Arrow | SmartArt] = Field( default_factory=list, description="Shapes overlapping the area." ) charts: list[Chart] = Field( From b1c3eed865a84684b606c7eb1dadbcb8074b20ec Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 16:53:49 +0900 Subject: [PATCH 05/14] task update --- docs/agents/TASKS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index c36719c..114f969 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -2,10 +2,10 @@ ## 1. 既存実装の修正(モデル分離の影響対応) -- [ ] `src/exstruct/io/__init__.py` の `_filter_shapes_to_area` が `list[Shape | Arrow | SmartArt]` を受け取れるように型と処理を調整する -- [ ] `src/exstruct/core/shapes.py` のコネクタ判定を `Arrow` 前提に変更する(`begin_arrow_style` / `end_arrow_style` などは `Arrow` のみ参照) -- [ ] `src/exstruct/core/shapes.py` の接続 ID 参照を `Arrow` に限定し、`Shape` からの誤参照を除去する -- [ ] `PrintAreaView` 側の `shapes` フィルタで `SmartArt` を落とさないことを確認する +- [x] `src/exstruct/io/__init__.py` の `_filter_shapes_to_area` が `list[Shape | Arrow | SmartArt]` を受け取れるように型と処理を調整する +- [x] `src/exstruct/core/shapes.py` のコネクタ判定を `Arrow` 前提に変更する(`begin_arrow_style` / `end_arrow_style` などは `Arrow` のみ参照) +- [x] `src/exstruct/core/shapes.py` の接続 ID 参照を `Arrow` に限定し、`Shape` からの誤参照を除去する +- [x] `PrintAreaView` 側の `shapes` フィルタで `SmartArt` を落とさないことを確認する ## 2. SmartArt 取得機能の実装方針 @@ -19,7 +19,7 @@ - [ ] `src/exstruct/core/shapes.py` に SmartArt 抽出用の関数を追加する(1 関数=1 責務を遵守) - [ ] `src/exstruct/core/shapes.py` のメイン抽出処理で `Shape` / `Arrow` / `SmartArt` に振り分ける -- [ ] `src/exstruct/io/__init__.py` で `Shape | Arrow | SmartArt` のシリアライズ挙動が崩れないことを確認する +- [x] `src/exstruct/io/__init__.py` で `Shape | Arrow | SmartArt` のシリアライズ挙動が崩れないことを確認する ## 4. 動作確認 From 8bcc866c89184c3e52723c5e929abd1aeaf7b9b2 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 17:05:53 +0900 Subject: [PATCH 06/14] =?UTF-8?q?SmartArt=E6=A9=9F=E8=83=BD=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0:=20SmartArt=E3=83=8E=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AE=E6=8A=BD=E5=87=BA=E3=81=A8=E3=83=8D=E3=82=B9=E3=83=88?= =?UTF-8?q?=E6=A7=8B=E9=80=A0=E3=81=AE=E6=A7=8B=E7=AF=89=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/exstruct/core/shapes.py | 142 ++++++++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index 931ae45..507146c 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -1,13 +1,13 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterable, Iterator import math -from typing import Literal, SupportsInt, cast +from typing import Literal, Protocol, SupportsInt, cast, runtime_checkable import xlwings as xw from xlwings import Book -from ..models import Arrow, Shape, SmartArt +from ..models import Arrow, Shape, SmartArt, SmartArtNode from ..models.maps import MSO_AUTO_SHAPE_TYPE_MAP, MSO_SHAPE_TYPE_MAP @@ -110,6 +110,113 @@ def _should_include_shape( return True +@runtime_checkable +class _TextRangeLike(Protocol): + """Text range interface for SmartArt nodes.""" + + Text: str | None + + +@runtime_checkable +class _TextFrameLike(Protocol): + """Text frame interface for SmartArt nodes.""" + + HasText: bool + TextRange: _TextRangeLike + + +@runtime_checkable +class _SmartArtNodeLike(Protocol): + """SmartArt node interface.""" + + Level: int + TextFrame2: _TextFrameLike + + +@runtime_checkable +class _SmartArtLike(Protocol): + """SmartArt interface.""" + + Layout: object + AllNodes: Iterable[_SmartArtNodeLike] + + +@runtime_checkable +class _ShapeWithSmartArtLike(Protocol): + """Shape interface exposing SmartArt.""" + + HasSmartArt: bool + SmartArt: _SmartArtLike + + +def _shape_has_smartart(shp: object) -> bool: + """Return True if the shape exposes SmartArt content.""" + return isinstance(shp, _ShapeWithSmartArtLike) and shp.HasSmartArt + + +def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: + """Return SmartArt layout name or a fallback label.""" + if smartart is None: + return "Unknown" + try: + layout = getattr(smartart, "Layout", None) + name = getattr(layout, "Name", None) + return str(name) if name is not None else "Unknown" + except Exception: + return "Unknown" + + +def _collect_smartart_node_info( + smartart: _SmartArtLike | None, +) -> list[tuple[int, str]]: + """Collect (level, text) pairs from SmartArt nodes.""" + nodes_info: list[tuple[int, str]] = [] + if smartart is None: + return nodes_info + try: + all_nodes = smartart.AllNodes + except Exception: + return nodes_info + + for node in all_nodes: + try: + level = int(node.Level) + except Exception: + continue + text = "" + try: + text_frame = node.TextFrame2 + if text_frame.HasText: + text_value = text_frame.TextRange.Text + text = str(text_value) if text_value is not None else "" + except Exception: + text = "" + nodes_info.append((level, text)) + return nodes_info + + +def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode]: + """Build nested SmartArtNode roots from flat (level, text) tuples.""" + roots: list[SmartArtNode] = [] + stack: list[SmartArtNode] = [] + for level, text in nodes_info: + node = SmartArtNode(text=text, level=level, children=[]) + while stack and stack[-1].level >= level: + stack.pop() + if stack: + stack[-1].children.append(node) + else: + roots.append(node) + stack.append(node) + return roots + + +def _extract_smartart_nodes(smartart: _SmartArtLike | None) -> list[SmartArtNode]: + """Extract SmartArt nodes as nested roots.""" + nodes_info = _collect_smartart_node_info(smartart) + return _build_smartart_tree(nodes_info) + + def get_shapes_with_position( # noqa: C901 workbook: Book, mode: str = "standard" ) -> dict[str, list[Shape | Arrow | SmartArt]]: @@ -150,7 +257,8 @@ def get_shapes_with_position( # noqa: C901 except Exception: text = "" - if not _should_include_shape( + has_smartart = _shape_has_smartart(shp) + if not has_smartart and not _should_include_shape( text=text, shape_type_num=type_num, shape_type_str=shape_type_str, @@ -195,8 +303,30 @@ def get_shapes_with_position( # noqa: C901 excel_name = shape_name if isinstance(shape_name, str) else None - if is_relationship_geom: - shape_obj: Shape | Arrow | SmartArt = Arrow( + shape_obj: Shape | Arrow | SmartArt + if has_smartart: + smartart_obj = None + try: + smartart_obj = shp.SmartArt + except Exception: + smartart_obj = None + shape_obj = SmartArt( + id=shape_id, + text=text, + l=int(shp.left), + t=int(shp.top), + w=int(shp.width) + if mode == "verbose" or shape_type_str == "Group" + else None, + h=int(shp.height) + if mode == "verbose" or shape_type_str == "Group" + else None, + type=type_label, + layout_name=_get_smartart_layout_name(smartart_obj), + roots=_extract_smartart_nodes(smartart_obj), + ) + elif is_relationship_geom: + shape_obj = Arrow( id=shape_id, text=text, l=int(shp.left), From 78301effa173391ffaaf6fad867dd9150e62da84 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 17:19:11 +0900 Subject: [PATCH 07/14] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AE=E3=82=AB=E3=83=90=E3=83=AC=E3=83=83?= =?UTF-8?q?=E3=82=B8=E3=81=AB=E9=96=A2=E3=81=99=E3=82=8B=E6=B3=A8=E6=84=8F?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81SmartArt=E3=81=8A?= =?UTF-8?q?=E3=82=88=E3=81=B3Arrow=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=B1=E3=83=BC=E3=82=B9=E3=82=92?= =?UTF-8?q?=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 5 +++ README.md | 8 +++-- docs/README.en.md | 6 ++++ docs/README.ja.md | 5 +++ docs/agents/TASKS.md | 29 +++++++---------- tests/com/test_shapes_extraction.py | 25 +++++++++------ tests/io/test_print_area_views.py | 29 +++++++++++++++-- tests/models/test_models_export.py | 32 ++++++++++++++++++- tests/models/test_models_validation.py | 43 ++++++++++++++++++++++++-- 9 files changed, 147 insertions(+), 35 deletions(-) diff --git a/README.ja.md b/README.ja.md index 2cb0d0c..0d3b85c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -396,6 +396,11 @@ ExStruct の内部実装を拡張する場合は、 → [docs/contributors/architecture.md](docs/contributors/architecture.md) +## カバレッジに関する注意 + +セル構造推論ロジック(cells.py)は、ヒューリスティックルールと +Excel 固有の動作に依存しています。網羅的なテストは現実世界の信頼性を反映できないため、完全なカバレッジは意図的に追求されていません。 + ## License BSD-3-Clause. See `LICENSE` for details. diff --git a/README.md b/README.md index 49b362e..455f954 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ [![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) - - ![ExStruct Image](/docs/assets/icon.webp) ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. @@ -397,6 +395,12 @@ please read the contributor architecture guide. → [docs/contributors/architecture.md](docs/contributors/architecture.md) +## Note on coverage + +The cell-structure inference logic (cells.py) relies on heuristic rules +and Excel-specific behaviors. Full coverage is intentionally not pursued, +as exhaustive testing would not reflect real-world reliability. + ## License BSD-3-Clause. See `LICENSE` for details. diff --git a/docs/README.en.md b/docs/README.en.md index 46c214b..68e3e86 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -398,6 +398,12 @@ please read the contributor architecture guide. → [docs/contributors/architecture.md](docs/contributors/architecture.md) +## Note on coverage + +The cell-structure inference logic (cells.py) relies on heuristic rules +and Excel-specific behaviors. Full coverage is intentionally not pursued, +as exhaustive testing would not reflect real-world reliability. + ## License BSD-3-Clause. See `LICENSE` for details. diff --git a/docs/README.ja.md b/docs/README.ja.md index 8ef6303..ad3763a 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -403,6 +403,11 @@ ExStruct の内部実装を拡張する場合は、 → [docs/contributors/architecture.md](docs/contributors/architecture.md) +## カバレッジに関する注意 + +セル構造推論ロジック(cells.py)は、ヒューリスティックルールと +Excel 固有の動作に依存しています。網羅的なテストは現実世界の信頼性を反映できないため、完全なカバレッジは意図的に追求されていません。 + ## License BSD-3-Clause. See `LICENSE` for details. diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 114f969..f6783b5 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -1,34 +1,29 @@ # Task List ## 1. 既存実装の修正(モデル分離の影響対応) - - [x] `src/exstruct/io/__init__.py` の `_filter_shapes_to_area` が `list[Shape | Arrow | SmartArt]` を受け取れるように型と処理を調整する - [x] `src/exstruct/core/shapes.py` のコネクタ判定を `Arrow` 前提に変更する(`begin_arrow_style` / `end_arrow_style` などは `Arrow` のみ参照) - [x] `src/exstruct/core/shapes.py` の接続 ID 参照を `Arrow` に限定し、`Shape` からの誤参照を除去する - [x] `PrintAreaView` 側の `shapes` フィルタで `SmartArt` を落とさないことを確認する ## 2. SmartArt 取得機能の実装方針 - -- [ ] `shape.HasSmartArt` を条件に SmartArt を抽出する -- [ ] `SmartArt.Layout.Name` を `SmartArt.layout_name` に格納する -- [ ] `SmartArt.AllNodes` を走査し、`level` と `text` を収集する -- [ ] ノード配列から `SmartArtNode` のツリー(`roots`)を構築する(`level` を使ったスタック組み立て) -- [ ] `SmartArt` は `BaseShape` 相当の位置/サイズ/回転/テキストを併せて格納する +- [x] `shape.HasSmartArt` を条件に SmartArt を抽出する +- [x] `SmartArt.Layout.Name` を `SmartArt.layout_name` に格納する +- [x] `SmartArt.AllNodes` を走査し、`level` と `text` を収集する +- [x] ノード配列から `SmartArtNode` のツリー(`roots`)を構築する(`level` を使ったスタック組み立て) +- [x] `SmartArt` は `BaseShape` 相当の位置/サイズ/回転/テキストを併せて格納する ## 3. 実装箇所の整理 - -- [ ] `src/exstruct/core/shapes.py` に SmartArt 抽出用の関数を追加する(1 関数=1 責務を遵守) -- [ ] `src/exstruct/core/shapes.py` のメイン抽出処理で `Shape` / `Arrow` / `SmartArt` に振り分ける +- [x] `src/exstruct/core/shapes.py` に SmartArt 抽出用の関数を追加する(1 関数=1 責務を遵守) +- [x] `src/exstruct/core/shapes.py` のメイン抽出処理で `Shape` / `Arrow` / `SmartArt` に振り分ける - [x] `src/exstruct/io/__init__.py` で `Shape | Arrow | SmartArt` のシリアライズ挙動が崩れないことを確認する ## 4. 動作確認 - -- [ ] 既存の shape / connector 抽出が壊れていないことを確認する +- [x] 既存の shape / connector 抽出が壊れていないことを確認する - [ ] SmartArt が含まれるブックで `SmartArt.roots` が期待どおりに出力されることを確認する ## 5. テストケース(カバレッジ維持) - -- [ ] `SmartArt` の `roots` がネスト構造でシリアライズされることを確認する -- [ ] `Arrow` のみが `begin_id` / `end_id` を持ち、`Shape` では参照されないことを確認する -- [ ] `_filter_shapes_to_area` が `Shape | Arrow | SmartArt` を受け取り、SmartArt も対象に含めることを確認する -- [ ] `kind` による判別が想定どおり動くことを確認する +- [x] `SmartArt` の `roots` がネスト構造でシリアライズされることを確認する +- [x] `Arrow` のみが `begin_id` / `end_id` を持ち、`Shape` では参照されないことを確認する +- [x] `_filter_shapes_to_area` が `Shape | Arrow | SmartArt` を受け取り、SmartArt も対象に含めることを確認する +- [x] `kind` による判別が想定どおり動くことを確認する diff --git a/tests/com/test_shapes_extraction.py b/tests/com/test_shapes_extraction.py index 47347e2..17dd24a 100644 --- a/tests/com/test_shapes_extraction.py +++ b/tests/com/test_shapes_extraction.py @@ -4,6 +4,7 @@ import xlwings as xw from exstruct.core.integrate import extract_workbook +from exstruct.models import Arrow pytestmark = pytest.mark.com @@ -86,11 +87,14 @@ def test_図形の種別とテキストが抽出される(tmp_path: Path) -> Non (s.text == "" or s.text is None) and (s.type or "").startswith("AutoShape") and not ( - s.direction - or s.begin_arrow_style is not None - or s.end_arrow_style is not None - or s.begin_id is not None - or s.end_id is not None + isinstance(s, Arrow) + and ( + s.direction + or s.begin_arrow_style is not None + or s.end_arrow_style is not None + or s.begin_id is not None + or s.end_id is not None + ) ) for s in shapes ) @@ -107,7 +111,8 @@ def test_線図形の方向と矢印情報が抽出される(tmp_path: Path) -> line = next( s for s in shapes - if s.begin_arrow_style is not None or s.end_arrow_style is not None + if isinstance(s, Arrow) + and (s.begin_arrow_style is not None or s.end_arrow_style is not None) ) assert line.direction == "E" @@ -121,14 +126,14 @@ def test_コネクターの接続元と接続先が抽出される(tmp_path: Pat shapes = wb_data.sheets["Sheet1"].shapes connectors = [ - s - for s in shapes - if s.begin_id is not None or s.end_id is not None + s for s in shapes if isinstance(s, Arrow) and (s.begin_id or s.end_id) ] # If the environment could not wire connectors, simply skip the assertion. if not connectors: - pytest.skip("Excel failed to populate ConnectorFormat.ConnectedShape properties.") + pytest.skip( + "Excel failed to populate ConnectorFormat.ConnectedShape properties." + ) conn = connectors[0] assert conn.begin_id is not None diff --git a/tests/io/test_print_area_views.py b/tests/io/test_print_area_views.py index a7c1a1e..30416e1 100644 --- a/tests/io/test_print_area_views.py +++ b/tests/io/test_print_area_views.py @@ -2,12 +2,34 @@ from pathlib import Path from exstruct.io import save_print_area_views -from exstruct.models import CellRow, Chart, PrintArea, Shape, SheetData, WorkbookData +from exstruct.models import ( + Arrow, + CellRow, + Chart, + PrintArea, + Shape, + SheetData, + SmartArt, + SmartArtNode, + WorkbookData, +) def _workbook_with_print_area() -> WorkbookData: shape_inside = Shape(id=1, text="inside", l=10, t=5, w=20, h=10, type="Rect") shape_outside = Shape(id=2, text="outside", l=200, t=200, w=30, h=30, type="Rect") + smartart_inside = SmartArt( + id=3, + text="sa", + l=15, + t=8, + w=20, + h=10, + type="SmartArt", + layout_name="Layout", + roots=[SmartArtNode(text="root", level=1, children=[])], + ) + arrow_inside = Arrow(id=None, text="", l=5, t=5, w=20, h=2, type="Line") chart_inside = Chart( name="c1", chart_type="Line", @@ -40,7 +62,7 @@ def _workbook_with_print_area() -> WorkbookData: CellRow(r=2, c={"1": "B"}), CellRow(r=3, c={"1": "C"}), ], - shapes=[shape_inside, shape_outside], + shapes=[shape_inside, smartart_inside, arrow_inside, shape_outside], charts=[chart_inside, chart_outside], table_candidates=["A1:B2", "C1:C1"], print_areas=[PrintArea(r1=1, c1=0, r2=2, c2=1)], @@ -61,7 +83,8 @@ def test_save_print_area_views_filters_rows_and_tables(tmp_path: Path) -> None: # Only table candidates fully contained in the print area remain. assert data["table_candidates"] == ["A1:B2"] # Shapes/Charts filtered by overlap; outside or size-less charts are dropped. - assert len(data["shapes"]) == 1 and data["shapes"][0]["text"] == "inside" + kinds = {shape["kind"] for shape in data["shapes"]} + assert kinds == {"shape", "smartart", "arrow"} assert len(data["charts"]) == 1 and data["charts"][0]["name"] == "c1" diff --git a/tests/models/test_models_export.py b/tests/models/test_models_export.py index 38080dd..f932061 100644 --- a/tests/models/test_models_export.py +++ b/tests/models/test_models_export.py @@ -1,10 +1,11 @@ from importlib import util +import json from pathlib import Path import pytest from exstruct.errors import MissingDependencyError -from exstruct.models import CellRow, SheetData, WorkbookData +from exstruct.models import CellRow, SheetData, SmartArt, SmartArtNode, WorkbookData HAS_PYYAML = util.find_spec("yaml") is not None HAS_TOON = util.find_spec("toon") is not None @@ -95,3 +96,32 @@ def test_workbook_iter_and_getitem() -> None: assert pairs[0][1] is first with pytest.raises(KeyError): _ = wb["Nope"] + + +def test_sheet_json_includes_smartart_roots() -> None: + smartart = SmartArt( + id=1, + text="sa", + l=0, + t=0, + w=10, + h=10, + layout_name="Layout", + roots=[ + SmartArtNode( + text="root", + level=1, + children=[SmartArtNode(text="child", level=2, children=[])], + ) + ], + ) + sheet = SheetData( + rows=[], + shapes=[smartart], + charts=[], + table_candidates=[], + ) + data = json.loads(sheet.to_json()) + assert data["shapes"][0]["kind"] == "smartart" + assert data["shapes"][0]["roots"][0]["text"] == "root" + assert data["shapes"][0]["roots"][0]["children"][0]["text"] == "child" diff --git a/tests/models/test_models_validation.py b/tests/models/test_models_validation.py index 1b57bf7..fe0122c 100644 --- a/tests/models/test_models_validation.py +++ b/tests/models/test_models_validation.py @@ -2,11 +2,14 @@ import pytest from exstruct.models import ( + Arrow, CellRow, Chart, ChartSeries, Shape, SheetData, + SmartArt, + SmartArtNode, WorkbookData, ) @@ -14,7 +17,25 @@ def test_モデルのデフォルトとオプション値() -> None: shape = Shape(id=1, text="t", l=1, t=2, w=None, h=None) assert shape.rotation is None - assert shape.direction is None + assert shape.kind == "shape" + + arrow = Arrow(id=None, text="a", l=1, t=1, w=10, h=1) + assert arrow.begin_arrow_style is None + assert arrow.end_arrow_style is None + assert arrow.kind == "arrow" + + smartart = SmartArt( + id=3, + text="sa", + l=5, + t=6, + w=50, + h=40, + layout_name="Layout", + roots=[SmartArtNode(text="root", level=1, children=[])], + ) + assert smartart.layout_name == "Layout" + assert smartart.roots[0].text == "root" cell = CellRow(r=1, c={"0": "v"}) assert cell.c["0"] == "v" @@ -48,7 +69,7 @@ def test_モデルのデフォルトとオプション値() -> None: def test_directionのリテラル検証() -> None: with pytest.raises(ValidationError): - Shape(id=1, text="bad", l=0, t=0, w=None, h=None, direction="X") + Arrow(id=1, text="bad", l=0, t=0, w=None, h=None, direction="X") def test_cellrowの数値正規化() -> None: @@ -56,3 +77,21 @@ def test_cellrowの数値正規化() -> None: assert isinstance(cell.c["0"], int) assert isinstance(cell.c["1"], float) assert cell.c["2"] == "text" + + +def test_arrow_only_fields_are_not_on_shape() -> None: + arrow = Arrow( + id=None, + text="a", + l=1, + t=1, + w=10, + h=2, + begin_id=1, + end_id=2, + ) + shape = Shape(id=1, text="s", l=0, t=0, w=None, h=None) + assert arrow.begin_id == 1 + assert arrow.end_id == 2 + assert not hasattr(shape, "begin_id") + assert not hasattr(shape, "end_id") From 4fd2612b38330491288422609bdafd25e50c4cfe Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 17:41:56 +0900 Subject: [PATCH 08/14] =?UTF-8?q?smartart=E3=82=92=E5=8F=96=E5=BE=97?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E4=B8=8D=E5=85=B7=E5=90=88?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sample/smartart/sample_smartart.xlsx | Bin 0 -> 62363 bytes src/exstruct/core/shapes.py | 16 ++++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 sample/smartart/sample_smartart.xlsx diff --git a/sample/smartart/sample_smartart.xlsx b/sample/smartart/sample_smartart.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7812f7cf3d41e11bcc8d7debfe597c15215bcbc4 GIT binary patch literal 62363 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 xTp&?U{N@p36XY=oa`CHK;^pFWi literal 0 HcmV?d00001 diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index 507146c..f3ae539 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -142,16 +142,20 @@ class _SmartArtLike(Protocol): @runtime_checkable -class _ShapeWithSmartArtLike(Protocol): - """Shape interface exposing SmartArt.""" +class _ShapeApiWithSmartArtLike(Protocol): + """COM shape interface exposing SmartArt.""" HasSmartArt: bool SmartArt: _SmartArtLike -def _shape_has_smartart(shp: object) -> bool: +def _shape_has_smartart(shp: xw.Shape) -> bool: """Return True if the shape exposes SmartArt content.""" - return isinstance(shp, _ShapeWithSmartArtLike) and shp.HasSmartArt + try: + api = shp.api + except Exception: + return False + return isinstance(api, _ShapeApiWithSmartArtLike) and api.HasSmartArt def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: @@ -305,9 +309,9 @@ def get_shapes_with_position( # noqa: C901 shape_obj: Shape | Arrow | SmartArt if has_smartart: - smartart_obj = None + smartart_obj: _SmartArtLike | None = None try: - smartart_obj = shp.SmartArt + smartart_obj = shp.api.SmartArt except Exception: smartart_obj = None shape_obj = SmartArt( From 25733f5ae2871febf17d0fee2f0d722f8668115b Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 17:59:19 +0900 Subject: [PATCH 09/14] =?UTF-8?q?SmartArt=E3=83=A2=E3=83=87=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E3=83=97=E3=83=AD=E3=83=91=E3=83=86=E3=82=A3=E5=90=8D?= =?UTF-8?q?=E3=82=92=E5=A4=89=E6=9B=B4:=20level=E3=82=92kids=E3=81=AB?= =?UTF-8?q?=E3=80=81roots=E3=82=92nodes=E3=81=AB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/DATA_MODEL.md | 9 ++++----- docs/agents/ROADMAP.md | 4 ++-- docs/agents/TASKS.md | 13 +++++++++---- src/exstruct/core/shapes.py | 27 +++++++++++--------------- src/exstruct/models/__init__.py | 9 +++------ tests/io/test_print_area_views.py | 4 ++-- tests/models/test_models_export.py | 13 ++++++------- tests/models/test_models_validation.py | 8 ++++---- 8 files changed, 41 insertions(+), 46 deletions(-) diff --git a/docs/agents/DATA_MODEL.md b/docs/agents/DATA_MODEL.md index 389d7e6..63f1057 100644 --- a/docs/agents/DATA_MODEL.md +++ b/docs/agents/DATA_MODEL.md @@ -44,14 +44,13 @@ Arrow extends BaseShape { SmartArtNode { text: str - level: int - children: [SmartArtNode] + kids: [SmartArtNode] } SmartArt extends BaseShape { kind: "smartart" - layout_name: str - roots: [SmartArtNode] + layout: str + nodes: [SmartArtNode] } ``` @@ -60,7 +59,7 @@ SmartArt extends BaseShape { - `direction` は線や矢印の向きを 8 方位に正規化したもの。 - 矢印スタイルは Excel の enum に対応。 - `begin_id` / `end_id` は、コネクタが接続している図形の `id` に対応(`ConnectorFormat.BeginConnectedShape` / `EndConnectedShape`)。 -- `SmartArtNode` はネスト構造で表現し、`roots` がツリーの根。 +- `SmartArtNode` はネスト構造で表現し、`nodes` がツリーの根。 --- diff --git a/docs/agents/ROADMAP.md b/docs/agents/ROADMAP.md index 47b77a0..f13ad1d 100644 --- a/docs/agents/ROADMAP.md +++ b/docs/agents/ROADMAP.md @@ -33,11 +33,11 @@ ## v0.3.1 -- ShapesとArrowsの分離(後のSmartArt追加のため) +- Shapes と Arrows の分離(後の SmartArt 追加のため) +- SmartArt 解析 ## v0.4.0 -- SmartArt 解析 - Excel Form Controls 解析 ## v1.0.0 diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index f6783b5..767d666 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -1,29 +1,34 @@ # Task List ## 1. 既存実装の修正(モデル分離の影響対応) + - [x] `src/exstruct/io/__init__.py` の `_filter_shapes_to_area` が `list[Shape | Arrow | SmartArt]` を受け取れるように型と処理を調整する - [x] `src/exstruct/core/shapes.py` のコネクタ判定を `Arrow` 前提に変更する(`begin_arrow_style` / `end_arrow_style` などは `Arrow` のみ参照) - [x] `src/exstruct/core/shapes.py` の接続 ID 参照を `Arrow` に限定し、`Shape` からの誤参照を除去する - [x] `PrintAreaView` 側の `shapes` フィルタで `SmartArt` を落とさないことを確認する ## 2. SmartArt 取得機能の実装方針 + - [x] `shape.HasSmartArt` を条件に SmartArt を抽出する -- [x] `SmartArt.Layout.Name` を `SmartArt.layout_name` に格納する +- [x] `SmartArt.Layout.Name` を `SmartArt.layout` に格納する - [x] `SmartArt.AllNodes` を走査し、`level` と `text` を収集する -- [x] ノード配列から `SmartArtNode` のツリー(`roots`)を構築する(`level` を使ったスタック組み立て) +- [x] ノード配列から `SmartArtNode` のツリー(`nodes`)を構築する(`level` を使ったスタック組み立て) - [x] `SmartArt` は `BaseShape` 相当の位置/サイズ/回転/テキストを併せて格納する ## 3. 実装箇所の整理 + - [x] `src/exstruct/core/shapes.py` に SmartArt 抽出用の関数を追加する(1 関数=1 責務を遵守) - [x] `src/exstruct/core/shapes.py` のメイン抽出処理で `Shape` / `Arrow` / `SmartArt` に振り分ける - [x] `src/exstruct/io/__init__.py` で `Shape | Arrow | SmartArt` のシリアライズ挙動が崩れないことを確認する ## 4. 動作確認 + - [x] 既存の shape / connector 抽出が壊れていないことを確認する -- [ ] SmartArt が含まれるブックで `SmartArt.roots` が期待どおりに出力されることを確認する +- [ ] SmartArt が含まれるブックで `SmartArt.nodes` が期待どおりに出力されることを確認する ## 5. テストケース(カバレッジ維持) -- [x] `SmartArt` の `roots` がネスト構造でシリアライズされることを確認する + +- [x] `SmartArt` の `nodes` がネスト構造でシリアライズされることを確認する - [x] `Arrow` のみが `begin_id` / `end_id` を持ち、`Shape` では参照されないことを確認する - [x] `_filter_shapes_to_area` が `Shape | Arrow | SmartArt` を受け取り、SmartArt も対象に含めることを確認する - [x] `kind` による判別が想定どおり動くことを確認する diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index f3ae539..24b4af9 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -141,21 +141,16 @@ class _SmartArtLike(Protocol): AllNodes: Iterable[_SmartArtNodeLike] -@runtime_checkable -class _ShapeApiWithSmartArtLike(Protocol): - """COM shape interface exposing SmartArt.""" - - HasSmartArt: bool - SmartArt: _SmartArtLike - - def _shape_has_smartart(shp: xw.Shape) -> bool: """Return True if the shape exposes SmartArt content.""" try: api = shp.api except Exception: return False - return isinstance(api, _ShapeApiWithSmartArtLike) and api.HasSmartArt + try: + return bool(api.HasSmartArt) + except Exception: + return False def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: @@ -202,16 +197,16 @@ def _collect_smartart_node_info( def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode]: """Build nested SmartArtNode roots from flat (level, text) tuples.""" roots: list[SmartArtNode] = [] - stack: list[SmartArtNode] = [] + stack: list[tuple[int, SmartArtNode]] = [] for level, text in nodes_info: - node = SmartArtNode(text=text, level=level, children=[]) - while stack and stack[-1].level >= level: + node = SmartArtNode(text=text, kids=[]) + while stack and stack[-1][0] >= level: stack.pop() if stack: - stack[-1].children.append(node) + stack[-1][1].kids.append(node) else: roots.append(node) - stack.append(node) + stack.append((level, node)) return roots @@ -326,8 +321,8 @@ def get_shapes_with_position( # noqa: C901 if mode == "verbose" or shape_type_str == "Group" else None, type=type_label, - layout_name=_get_smartart_layout_name(smartart_obj), - roots=_extract_smartart_nodes(smartart_obj), + layout=_get_smartart_layout_name(smartart_obj), + nodes=_extract_smartart_nodes(smartart_obj), ) elif is_relationship_geom: shape_obj = Arrow( diff --git a/src/exstruct/models/__init__.py b/src/exstruct/models/__init__.py index 8db5df5..47da2d9 100644 --- a/src/exstruct/models/__init__.py +++ b/src/exstruct/models/__init__.py @@ -63,18 +63,15 @@ class SmartArtNode(BaseModel): """Node of SmartArt hierarchy.""" text: str = Field(description="Visible text for the node.") - level: int = Field(description="Node depth level.") - children: list[SmartArtNode] = Field( - default_factory=list, description="Child nodes." - ) + kids: list[SmartArtNode] = Field(default_factory=list, description="Child nodes.") class SmartArt(BaseShape): """SmartArt shape metadata with nested nodes.""" kind: Literal["smartart"] = Field(default="smartart", description="Shape kind.") - layout_name: str = Field(description="SmartArt layout name.") - roots: list[SmartArtNode] = Field( + layout: str = Field(description="SmartArt layout name.") + nodes: list[SmartArtNode] = Field( default_factory=list, description="Root nodes of SmartArt tree." ) diff --git a/tests/io/test_print_area_views.py b/tests/io/test_print_area_views.py index 30416e1..3498730 100644 --- a/tests/io/test_print_area_views.py +++ b/tests/io/test_print_area_views.py @@ -26,8 +26,8 @@ def _workbook_with_print_area() -> WorkbookData: w=20, h=10, type="SmartArt", - layout_name="Layout", - roots=[SmartArtNode(text="root", level=1, children=[])], + layout="Layout", + nodes=[SmartArtNode(text="root", kids=[])], ) arrow_inside = Arrow(id=None, text="", l=5, t=5, w=20, h=2, type="Line") chart_inside = Chart( diff --git a/tests/models/test_models_export.py b/tests/models/test_models_export.py index f932061..9d110e5 100644 --- a/tests/models/test_models_export.py +++ b/tests/models/test_models_export.py @@ -98,7 +98,7 @@ def test_workbook_iter_and_getitem() -> None: _ = wb["Nope"] -def test_sheet_json_includes_smartart_roots() -> None: +def test_sheet_json_includes_smartart_nodes() -> None: smartart = SmartArt( id=1, text="sa", @@ -106,12 +106,11 @@ def test_sheet_json_includes_smartart_roots() -> None: t=0, w=10, h=10, - layout_name="Layout", - roots=[ + layout="Layout", + nodes=[ SmartArtNode( text="root", - level=1, - children=[SmartArtNode(text="child", level=2, children=[])], + kids=[SmartArtNode(text="child", kids=[])], ) ], ) @@ -123,5 +122,5 @@ def test_sheet_json_includes_smartart_roots() -> None: ) data = json.loads(sheet.to_json()) assert data["shapes"][0]["kind"] == "smartart" - assert data["shapes"][0]["roots"][0]["text"] == "root" - assert data["shapes"][0]["roots"][0]["children"][0]["text"] == "child" + assert data["shapes"][0]["nodes"][0]["text"] == "root" + assert data["shapes"][0]["nodes"][0]["kids"][0]["text"] == "child" diff --git a/tests/models/test_models_validation.py b/tests/models/test_models_validation.py index fe0122c..3bb45dd 100644 --- a/tests/models/test_models_validation.py +++ b/tests/models/test_models_validation.py @@ -31,11 +31,11 @@ def test_モデルのデフォルトとオプション値() -> None: t=6, w=50, h=40, - layout_name="Layout", - roots=[SmartArtNode(text="root", level=1, children=[])], + layout="Layout", + nodes=[SmartArtNode(text="root", kids=[])], ) - assert smartart.layout_name == "Layout" - assert smartart.roots[0].text == "root" + assert smartart.layout == "Layout" + assert smartart.nodes[0].text == "root" cell = CellRow(r=1, c={"0": "v"}) assert cell.c["0"] == "v" From 0a4b33b0d5112e21a9e0267268ca15a8ef8bc21c Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 18:25:19 +0900 Subject: [PATCH 10/14] =?UTF-8?q?-=20Shape=20=E3=81=AE=E3=81=BF=20type=20?= =?UTF-8?q?=E3=82=92=E4=BF=9D=E6=8C=81=EF=BC=88BaseShape=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=89=8A=E9=99=A4=EF=BC=89=20-=20Arrow=20/=20SmartArt?= =?UTF-8?q?=20=E3=81=AE=E7=94=9F=E6=88=90=E6=99=82=E3=81=AB=20type=20?= =?UTF-8?q?=E3=82=92=E4=BB=98=E4=B8=8E=E3=81=97=E3=81=AA=E3=81=84=20-=20?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=20type=20=E5=8F=82?= =?UTF-8?q?=E7=85=A7=E3=82=92=20Shape=20=E3=81=AB=E9=99=90=E5=AE=9A=20-=20?= =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=BF=E3=83=A2=E3=83=87=E3=83=AB=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AE=E8=A8=98=E8=BF=B0=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- docs/agents/DATA_MODEL.md | 2 +- sample/basic/sample.json | 95 +++++--------- sample/flowchart/sample-shape-connector.json | 131 +++++++++++-------- sample/smartart/sample_smartart.json | 93 +++++++++++++ sample/smartart/sample_smartart_for_llm.md | 92 +++++++++++++ src/exstruct/core/shapes.py | 2 - src/exstruct/models/__init__.py | 2 +- tests/com/test_shapes_extraction.py | 27 ++-- tests/core/test_mode_output.py | 9 +- tests/core/test_shapes_positions_dummy.py | 4 +- tests/io/test_print_area_views.py | 3 +- 12 files changed, 313 insertions(+), 150 deletions(-) create mode 100644 sample/smartart/sample_smartart.json create mode 100644 sample/smartart/sample_smartart_for_llm.md diff --git a/.gitignore b/.gitignore index 9a7167d..156eec4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,4 @@ output.json ruff_report.txt mypy_report.txt coverage.xml -htmlcov/ -sample_smartart.py \ No newline at end of file +htmlcov/ \ No newline at end of file diff --git a/docs/agents/DATA_MODEL.md b/docs/agents/DATA_MODEL.md index 63f1057..3ed8c7f 100644 --- a/docs/agents/DATA_MODEL.md +++ b/docs/agents/DATA_MODEL.md @@ -25,12 +25,12 @@ BaseShape { t: int // top (px) w: int | null // width (px) h: int | null // height(px) - type: str | null // MSO 図形タイプラベル rotation: float | null } Shape extends BaseShape { kind: "shape" + type: str | null // MSO 図形タイプラベル } Arrow extends BaseShape { diff --git a/sample/basic/sample.json b/sample/basic/sample.json index a2c72fb..d64a4d1 100644 --- a/sample/basic/sample.json +++ b/sample/basic/sample.json @@ -5,66 +5,31 @@ "rows": [ { "r": 3, - "c": { - "1": "月", - "2": "製品A", - "3": "製品B", - "4": "製品C" - } + "c": { "1": "月", "2": "製品A", "3": "製品B", "4": "製品C" } }, { "r": 4, - "c": { - "1": "2025-01-01 00:00:00", - "2": 120, - "3": 80, - "4": 60 - } + "c": { "1": "2025-01-01 00:00:00", "2": 120, "3": 80, "4": 60 } }, { "r": 5, - "c": { - "1": "2025-02-01 00:00:00", - "2": 135, - "3": 90, - "4": 64 - } + "c": { "1": "2025-02-01 00:00:00", "2": 135, "3": 90, "4": 64 } }, { "r": 6, - "c": { - "1": "2025-03-01 00:00:00", - "2": 150, - "3": 100, - "4": 70 - } + "c": { "1": "2025-03-01 00:00:00", "2": 150, "3": 100, "4": 70 } }, { "r": 7, - "c": { - "1": "2025-04-01 00:00:00", - "2": 170, - "3": 110, - "4": 72 - } + "c": { "1": "2025-04-01 00:00:00", "2": 170, "3": 110, "4": 72 } }, { "r": 8, - "c": { - "1": "2025-05-01 00:00:00", - "2": 160, - "3": 120, - "4": 75 - } + "c": { "1": "2025-05-01 00:00:00", "2": 160, "3": 120, "4": 75 } }, { "r": 9, - "c": { - "1": "2025-06-01 00:00:00", - "2": 180, - "3": 130, - "4": 80 - } + "c": { "1": "2025-06-01 00:00:00", "2": 180, "3": 130, "4": 80 } } ], "shapes": [ @@ -73,6 +38,7 @@ "text": "開始", "l": 148, "t": 220, + "kind": "shape", "type": "AutoShape-FlowchartProcess" }, { @@ -80,12 +46,13 @@ "text": "入力データ読み込み", "l": 132, "t": 282, + "kind": "shape", "type": "AutoShape-FlowchartProcess" }, { "l": 193, "t": 246, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 1, @@ -97,6 +64,7 @@ "text": "形式は正しい?", "l": 90, "t": 342, + "kind": "shape", "type": "AutoShape-FlowchartDecision" }, { @@ -104,6 +72,7 @@ "text": "1件処理", "l": 424, "t": 361, + "kind": "shape", "type": "AutoShape-FlowchartProcess" }, { @@ -111,12 +80,13 @@ "text": "残件あり?", "l": 365, "t": 414, + "kind": "shape", "type": "AutoShape-FlowchartDecision" }, { "l": 192, "t": 312, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 2, @@ -126,7 +96,7 @@ { "l": 295, "t": 374, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 3, @@ -138,12 +108,13 @@ "text": "はい", "l": 340, "t": 362, + "kind": "shape", "type": "TextBox-Rectangle" }, { "l": 468, "t": 387, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 4, @@ -155,6 +126,7 @@ "text": "出力を生成", "l": 426, "t": 494, + "kind": "shape", "type": "AutoShape-FlowchartProcess" }, { @@ -162,6 +134,7 @@ "text": "メール送信?", "l": 366, "t": 549, + "kind": "shape", "type": "AutoShape-FlowchartDecision" }, { @@ -169,12 +142,13 @@ "text": "エラー表示", "l": 132, "t": 463, + "kind": "shape", "type": "AutoShape-FlowchartProcess" }, { "l": 192, "t": 406, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 3, @@ -186,12 +160,13 @@ "text": "メール送信", "l": 426, "t": 638, + "kind": "shape", "type": "AutoShape-FlowchartProcess" }, { "l": 468, "t": 466, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 5, @@ -201,7 +176,7 @@ { "l": 468, "t": 520, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 7, @@ -213,12 +188,13 @@ "text": "終了", "l": 273, "t": 684, + "kind": "shape", "type": "AutoShape-FlowchartProcess" }, { "l": 194, "t": 493, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 9, @@ -228,7 +204,7 @@ { "l": 363, "t": 664, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 10, @@ -238,7 +214,7 @@ { "l": 468, "t": 598, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 8, @@ -250,12 +226,13 @@ "text": "はい", "l": 448, "t": 604, + "kind": "shape", "type": "TextBox-Rectangle" }, { "l": 323, "t": 573, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 8, @@ -266,6 +243,7 @@ "text": "いいえ", "l": 319, "t": 600, + "kind": "shape", "type": "TextBox-Rectangle" } ], @@ -274,10 +252,7 @@ "name": "Chart 1", "chart_type": "Line", "title": "売上データ", - "y_axis_range": [ - 0.0, - 200.0 - ], + "y_axis_range": [0.0, 200.0], "series": [ { "name": "製品A", @@ -302,9 +277,7 @@ "t": 25 } ], - "table_candidates": [ - "B3:E9" - ] + "table_candidates": ["B3:E9"] } } -} \ No newline at end of file +} diff --git a/sample/flowchart/sample-shape-connector.json b/sample/flowchart/sample-shape-connector.json index f1d0f90..b91c9b7 100644 --- a/sample/flowchart/sample-shape-connector.json +++ b/sample/flowchart/sample-shape-connector.json @@ -6,59 +6,65 @@ { "id": 1, "text": "S", - "l": 81, + "l": 80, "t": 45, + "kind": "shape", "type": "AutoShape-Oval" }, { "id": 2, "text": "E", - "l": 549, + "l": 545, "t": 696, + "kind": "shape", "type": "AutoShape-Oval" }, { "id": 3, "text": "要件抽出", - "l": 81, + "l": 80, "t": 168, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "l": 102, - "t": 87, - "type": "AutoShape-Mixed", + "t": 88, + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 1, "end_id": 3, - "direction": "NE" + "direction": "N" }, { "id": 4, "text": "ヒアリング", - "l": 342, + "l": 340, "t": 97, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 5, "text": "非機能要件", - "l": 210, - "t": 225, + "l": 209, + "t": 226, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 6, "text": "機能要件", - "l": 405, + "l": 402, "t": 210, + "kind": "shape", "type": "AutoShape-Rectangle" }, { - "l": 191, + "l": 190, "t": 120, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 3, @@ -66,9 +72,9 @@ "direction": "NE" }, { - "l": 266, + "l": 264, "t": 143, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 4, @@ -76,9 +82,9 @@ "direction": "NE" }, { - "l": 398, + "l": 395, "t": 143, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 4, @@ -88,63 +94,71 @@ { "id": 7, "text": "プロトタイプ", - "l": 381, - "t": 291, + "l": 379, + "t": 292, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 8, "text": "実験検証", - "l": 388, + "l": 385, "t": 389, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 9, "text": "思考実験", - "l": 82, + "l": 81, "t": 325, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 10, "text": "再検証", - "l": 182, + "l": 181, "t": 426, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 11, "text": "まとめ", - "l": 252, - "t": 510, + "l": 251, + "t": 511, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 12, "text": "文書作成", - "l": 296, + "l": 294, "t": 589, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 13, "text": "契約管理", - "l": 489, + "l": 486, "t": 509, + "kind": "shape", "type": "AutoShape-Rectangle" }, { "id": 14, "text": "締結", - "l": 356, - "t": 675, + "l": 353, + "t": 676, + "kind": "shape", "type": "AutoShape-Rectangle" }, { - "l": 144, + "l": 143, "t": 271, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 5, @@ -152,9 +166,9 @@ "direction": "NE" }, { - "l": 144, + "l": 143, "t": 371, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 9, @@ -162,9 +176,9 @@ "direction": "NE" }, { - "l": 244, + "l": 242, "t": 471, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 10, @@ -172,9 +186,9 @@ "direction": "NE" }, { - "l": 314, + "l": 312, "t": 556, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 11, @@ -182,9 +196,9 @@ "direction": "NE" }, { - "l": 376, + "l": 373, "t": 531, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 11, @@ -192,9 +206,9 @@ "direction": "E" }, { - "l": 357, + "l": 355, "t": 635, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 12, @@ -202,9 +216,9 @@ "direction": "NE" }, { - "l": 417, + "l": 414, "t": 554, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 13, @@ -212,9 +226,9 @@ "direction": "NE" }, { - "l": 479, + "l": 476, "t": 698, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 14, @@ -222,9 +236,9 @@ "direction": "E" }, { - "l": 443, - "t": 255, - "type": "AutoShape-Mixed", + "l": 440, + "t": 256, + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 6, @@ -232,9 +246,9 @@ "direction": "NE" }, { - "l": 443, + "l": 440, "t": 337, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 7, @@ -242,9 +256,9 @@ "direction": "N" }, { - "l": 314, + "l": 312, "t": 434, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 8, @@ -252,10 +266,10 @@ "direction": "NE" }, { - "l": 194, + "l": 192, "t": 298, - "type": "AutoShape-Mixed", "rotation": 90.0, + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 10, @@ -263,9 +277,9 @@ "direction": "NE" }, { - "l": 511, + "l": 508, "t": 308, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 8, @@ -275,14 +289,15 @@ { "id": 15, "text": "機能追加", - "l": 581, + "l": 577, "t": 263, + "kind": "shape", "type": "AutoShape-Rectangle" }, { - "l": 505, + "l": 501, "t": 285, - "type": "AutoShape-Mixed", + "kind": "arrow", "begin_arrow_style": 1, "end_arrow_style": 2, "begin_id": 15, @@ -292,4 +307,4 @@ ] } } -} \ No newline at end of file +} diff --git a/sample/smartart/sample_smartart.json b/sample/smartart/sample_smartart.json new file mode 100644 index 0000000..07f112a --- /dev/null +++ b/sample/smartart/sample_smartart.json @@ -0,0 +1,93 @@ +{ + "book_name": "sample_smartart.xlsx", + "sheets": { + "Sheet1": { + "shapes": [ + { + "id": 1, + "l": 0, + "t": 28, + "kind": "smartart", + "layout": "基本の循環", + "nodes": [ + { "text": "1", "kids": [{ "text": "要件定義" }] }, + { "text": "2", "kids": [{ "text": "報連相" }, { "text": "開発" }] }, + { + "text": "3", + "kids": [{ "text": "実装確認" }, { "text": "動作確認" }] + }, + { "text": "4", "kids": [{ "text": "対策" }] }, + { "text": "5", "kids": [{ "text": "最終確認" }] } + ] + }, + { + "id": 2, + "l": 388, + "t": 32, + "kind": "smartart", + "layout": "開始点強調型プロセス", + "nodes": [ + { "text": "企画" }, + { "text": "執筆" }, + { "text": "編集" }, + { "text": "制作" }, + { "text": "校正" } + ] + }, + { + "id": 3, + "l": 46, + "t": 325, + "kind": "smartart", + "layout": "組織図", + "nodes": [ + { + "text": "取締役会", + "kids": [ + { + "text": "社長", + "kids": [ + { "text": "企画管理部" }, + { + "text": "営業部", + "kids": [ + { "text": "第1営業課" }, + { "text": "第2営業課" }, + { "text": "第3営業課" }, + { "text": "海外営業課" } + ] + }, + { + "text": "開発部", + "kids": [{ "text": "第1開発課" }, { "text": "第2開発課" }] + }, + { + "text": "技術部", + "kids": [{ "text": "第1技術課" }, { "text": "第2技術課" }] + }, + { + "text": "生産部", + "kids": [ + { "text": "愛知工場" }, + { "text": "山形工場" }, + { "text": "高知工場" } + ] + }, + { + "text": "総務部", + "kids": [ + { "text": "総務課" }, + { "text": "人事課" }, + { "text": "経理課" } + ] + } + ] + } + ] + } + ] + } + ] + } + } +} diff --git a/sample/smartart/sample_smartart_for_llm.md b/sample/smartart/sample_smartart_for_llm.md new file mode 100644 index 0000000..b453d06 --- /dev/null +++ b/sample/smartart/sample_smartart_for_llm.md @@ -0,0 +1,92 @@ +# 📘 sample_smartart.xlsx + +## 1. 基本の循環(SmartArt) + +- **1** + - 要件定義 +- **2** + - 報連相 + - 開発 +- **3** + - 実装確認 + - 動作確認 +- **4** + - 対策 +- **5** + - 最終確認 + +--- + +## 2. 開始点強調型プロセス(SmartArt) + +1. 企画 +2. 執筆 +3. 編集 +4. 制作 +5. 校正 + +```mermaid +flowchart LR + B1["企画"] --> B2["執筆"] --> B3["編集"] --> B4["制作"] --> B5["校正"] +``` + +--- + +## 3. 組織図(SmartArt) + +- **取締役会** + - **社長** + - 企画管理部 + - 営業部 + - 第 1 営業課 + - 第 2 営業課 + - 第 3 営業課 + - 海外営業課 + - 開発部 + - 第 1 開発課 + - 第 2 開発課 + - 技術部 + - 第 1 技術課 + - 第 2 技術課 + - 生産部 + - 愛知工場 + - 山形工場 + - 高知工場 + - 総務部 + - 総務課 + - 人事課 + - 経理課 + +```mermaid +flowchart TB + T["取締役会"] + P["社長"] + + T --> P + + P --> K1["企画管理部"] + + P --> E["営業部"] + E --> E1["第1営業課"] + E --> E2["第2営業課"] + E --> E3["第3営業課"] + E --> E4["海外営業課"] + + P --> D["開発部"] + D --> D1["第1開発課"] + D --> D2["第2開発課"] + + P --> G["技術部"] + G --> G1["第1技術課"] + G --> G2["第2技術課"] + + P --> S["生産部"] + S --> S1["愛知工場"] + S --> S2["山形工場"] + S --> S3["高知工場"] + + P --> A["総務部"] + A --> A1["総務課"] + A --> A2["人事課"] + A --> A3["経理課"] +``` diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index 24b4af9..a33bbdc 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -320,7 +320,6 @@ def get_shapes_with_position( # noqa: C901 h=int(shp.height) if mode == "verbose" or shape_type_str == "Group" else None, - type=type_label, layout=_get_smartart_layout_name(smartart_obj), nodes=_extract_smartart_nodes(smartart_obj), ) @@ -336,7 +335,6 @@ def get_shapes_with_position( # noqa: C901 h=int(shp.height) if mode == "verbose" or shape_type_str == "Group" else None, - type=type_label, ) else: shape_obj = Shape( diff --git a/src/exstruct/models/__init__.py b/src/exstruct/models/__init__.py index 47da2d9..bd40d0b 100644 --- a/src/exstruct/models/__init__.py +++ b/src/exstruct/models/__init__.py @@ -20,7 +20,6 @@ class BaseShape(BaseModel): t: int = Field(description="Top offset (Excel units).") w: int | None = Field(default=None, description="Shape width (None if unknown).") h: int | None = Field(default=None, description="Shape height (None if unknown).") - type: str | None = Field(default=None, description="Excel shape type name.") rotation: float | None = Field( default=None, description="Rotation angle in degrees." ) @@ -30,6 +29,7 @@ class Shape(BaseShape): """Normal shape metadata.""" kind: Literal["shape"] = Field(default="shape", description="Shape kind.") + type: str | None = Field(default=None, description="Excel shape type name.") class Arrow(BaseShape): diff --git a/tests/com/test_shapes_extraction.py b/tests/com/test_shapes_extraction.py index 17dd24a..be31e2d 100644 --- a/tests/com/test_shapes_extraction.py +++ b/tests/com/test_shapes_extraction.py @@ -4,7 +4,7 @@ import xlwings as xw from exstruct.core.integrate import extract_workbook -from exstruct.models import Arrow +from exstruct.models import Arrow, Shape pytestmark = pytest.mark.com @@ -71,31 +71,22 @@ def test_図形の種別とテキストが抽出される(tmp_path: Path) -> Non wb_data = extract_workbook(path) shapes = wb_data.sheets["Sheet1"].shapes - rect = next(s for s in shapes if s.text == "rect") + rect = next(s for s in shapes if isinstance(s, Shape) and s.text == "rect") assert "AutoShape" in (rect.type or "") assert rect.l >= 0 and rect.t >= 0 - assert rect.id > 0 + assert rect.id is not None and rect.id > 0 - inner = next(s for s in shapes if s.text == "inner") + inner = next(s for s in shapes if isinstance(s, Shape) and s.text == "inner") assert "Group" not in (inner.type or "") # flattened child - assert not any((s.type or "") == "Group" for s in shapes) - assert inner.id > 0 + assert not any(isinstance(s, Shape) and (s.type or "") == "Group" for s in shapes) + assert inner.id is not None and inner.id > 0 ids = [s.id for s in shapes if s.id is not None] assert len(ids) == len(set(ids)) # Standard mode should not emit non-relationship AutoShapes without text. assert not any( - (s.text == "" or s.text is None) + isinstance(s, Shape) + and (s.text == "" or s.text is None) and (s.type or "").startswith("AutoShape") - and not ( - isinstance(s, Arrow) - and ( - s.direction - or s.begin_arrow_style is not None - or s.end_arrow_style is not None - or s.begin_id is not None - or s.end_id is not None - ) - ) for s in shapes ) @@ -140,6 +131,6 @@ def test_コネクターの接続元と接続先が抽出される(tmp_path: Pat assert conn.end_id is not None assert conn.begin_id != conn.end_id # Connected shape ids should correspond to some emitted shapes' id. - shape_ids = {s.id for s in shapes} + shape_ids = {s.id for s in shapes if s.id is not None} assert conn.begin_id in shape_ids assert conn.end_id in shape_ids diff --git a/tests/core/test_mode_output.py b/tests/core/test_mode_output.py index 4d900f8..06e5930 100644 --- a/tests/core/test_mode_output.py +++ b/tests/core/test_mode_output.py @@ -10,6 +10,7 @@ import xlwings as xw from exstruct import extract, process_excel +from exstruct.models import Arrow def _make_basic_book(path: Path) -> None: @@ -78,8 +79,8 @@ def test_standardモードはテキストなし図形を除外する(tmp_path: P for s in shapes: if s.text != "": continue - assert s.type is not None - assert ("Line" in s.type) or ("Connector" in s.type) or ("Arrow" in s.type) + assert isinstance(s, Arrow) + assert s.direction is not None or s.begin_arrow_style is not None def test_verboseモードでは全図形と幅高さが出力される(tmp_path: Path) -> None: @@ -108,11 +109,11 @@ def test_invalidモードはエラーになる(tmp_path: Path) -> None: path = tmp_path / "book.xlsx" _make_basic_book(path) with pytest.raises(ValueError): - extract(path, mode="invalid") + extract(path, mode="invalid") # type: ignore[arg-type] out = tmp_path / "out.json" with pytest.raises(ValueError): - process_excel(path, out, mode="invalid") + process_excel(path, out, mode="invalid") # type: ignore[arg-type] def test_CLIのmode引数バリデーション(tmp_path: Path) -> None: diff --git a/tests/core/test_shapes_positions_dummy.py b/tests/core/test_shapes_positions_dummy.py index 13e228f..071db23 100644 --- a/tests/core/test_shapes_positions_dummy.py +++ b/tests/core/test_shapes_positions_dummy.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from exstruct.core.shapes import get_shapes_with_position +from exstruct.models import Arrow @dataclass(frozen=True) @@ -107,7 +108,8 @@ def test_get_shapes_with_position_standard_filters_textless_non_relation() -> No assert len(shapes) == 2 assert {s.text for s in shapes} == {"Hello", ""} line_entries = [s for s in shapes if s.text == ""] - assert line_entries[0].type == "Line" + assert isinstance(line_entries[0], Arrow) + assert line_entries[0].direction == "E" text_entries = [s for s in shapes if s.text == "Hello"] assert text_entries[0].id == 1 diff --git a/tests/io/test_print_area_views.py b/tests/io/test_print_area_views.py index 3498730..8e61e90 100644 --- a/tests/io/test_print_area_views.py +++ b/tests/io/test_print_area_views.py @@ -25,11 +25,10 @@ def _workbook_with_print_area() -> WorkbookData: t=8, w=20, h=10, - type="SmartArt", layout="Layout", nodes=[SmartArtNode(text="root", kids=[])], ) - arrow_inside = Arrow(id=None, text="", l=5, t=5, w=20, h=2, type="Line") + arrow_inside = Arrow(id=None, text="", l=5, t=5, w=20, h=2) chart_inside = Chart( name="c1", chart_type="Line", From 697bdf0d611cf343c4edb3b59eed37b2b8aed531 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 18:26:22 +0900 Subject: [PATCH 11/14] =?UTF-8?q?json=E3=82=B9=E3=82=AD=E3=83=BC=E3=83=9E?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schemas/print_area_view.json | 316 ++++++++++++++++++++++++----- schemas/shape.json | 84 +------- schemas/sheet.json | 316 ++++++++++++++++++++++++----- schemas/workbook.json | 380 ++++++++++++++++++++++++++++------- 4 files changed, 853 insertions(+), 243 deletions(-) diff --git a/schemas/print_area_view.json b/schemas/print_area_view.json index d718773..38b57e2 100644 --- a/schemas/print_area_view.json +++ b/schemas/print_area_view.json @@ -1,5 +1,166 @@ { "$defs": { + "Arrow": { + "description": "Connector shape metadata.", + "properties": { + "begin_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the start of a connector.", + "title": "Begin Arrow Style" + }, + "begin_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", + "title": "Begin Id" + }, + "direction": { + "anyOf": [ + { + "enum": [ + "E", + "SE", + "S", + "SW", + "W", + "NW", + "N", + "NE" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Connector direction (compass heading).", + "title": "Direction" + }, + "end_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the end of a connector.", + "title": "End Arrow Style" + }, + "end_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", + "title": "End Id" + }, + "h": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape height (None if unknown).", + "title": "H" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" + }, + "kind": { + "const": "arrow", + "default": "arrow", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "rotation": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Rotation angle in degrees.", + "title": "Rotation" + }, + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "w": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t" + ], + "title": "Arrow", + "type": "object" + }, "CellRow": { "description": "A single row of cells with optional hyperlinks.", "properties": { @@ -246,9 +407,9 @@ "type": "object" }, "Shape": { - "description": "Shape metadata (position, size, text, and styling).", + "description": "Normal shape metadata.", "properties": { - "begin_arrow_style": { + "h": { "anyOf": [ { "type": "integer" @@ -258,10 +419,10 @@ } ], "default": null, - "description": "Arrow style enum for the start of a connector.", - "title": "Begin Arrow Style" + "description": "Shape height (None if unknown).", + "title": "H" }, - "begin_id": { + "id": { "anyOf": [ { "type": "integer" @@ -271,46 +432,58 @@ } ], "default": null, - "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", - "title": "Begin Id" + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" }, - "direction": { + "kind": { + "const": "shape", + "default": "shape", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "rotation": { "anyOf": [ { - "enum": [ - "E", - "SE", - "S", - "SW", - "W", - "NW", - "N", - "NE" - ], - "type": "string" + "type": "number" }, { "type": "null" } ], "default": null, - "description": "Connector direction (compass heading).", - "title": "Direction" + "description": "Rotation angle in degrees.", + "title": "Rotation" }, - "end_arrow_style": { + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "type": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], "default": null, - "description": "Arrow style enum for the end of a connector.", - "title": "End Arrow Style" + "description": "Excel shape type name.", + "title": "Type" }, - "end_id": { + "w": { "anyOf": [ { "type": "integer" @@ -320,9 +493,21 @@ } ], "default": null, - "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", - "title": "End Id" - }, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t" + ], + "title": "Shape", + "type": "object" + }, + "SmartArt": { + "description": "SmartArt shape metadata with nested nodes.", + "properties": { "h": { "anyOf": [ { @@ -349,11 +534,31 @@ "description": "Sequential shape id within the sheet (if applicable).", "title": "Id" }, + "kind": { + "const": "smartart", + "default": "smartart", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, "l": { "description": "Left offset (Excel units).", "title": "L", "type": "integer" }, + "layout": { + "description": "SmartArt layout name.", + "title": "Layout", + "type": "string" + }, + "nodes": { + "description": "Root nodes of SmartArt tree.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Nodes", + "type": "array" + }, "rotation": { "anyOf": [ { @@ -377,19 +582,6 @@ "title": "Text", "type": "string" }, - "type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Excel shape type name.", - "title": "Type" - }, "w": { "anyOf": [ { @@ -407,9 +599,33 @@ "required": [ "text", "l", - "t" + "t", + "layout" ], - "title": "Shape", + "title": "SmartArt", + "type": "object" + }, + "SmartArtNode": { + "description": "Node of SmartArt hierarchy.", + "properties": { + "kids": { + "description": "Child nodes.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Kids", + "type": "array" + }, + "text": { + "description": "Visible text for the node.", + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "SmartArtNode", "type": "object" } }, @@ -444,7 +660,17 @@ "shapes": { "description": "Shapes overlapping the area.", "items": { - "$ref": "#/$defs/Shape" + "anyOf": [ + { + "$ref": "#/$defs/Shape" + }, + { + "$ref": "#/$defs/Arrow" + }, + { + "$ref": "#/$defs/SmartArt" + } + ] }, "title": "Shapes", "type": "array" diff --git a/schemas/shape.json b/schemas/shape.json index dff32d0..8f76162 100644 --- a/schemas/shape.json +++ b/schemas/shape.json @@ -1,82 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Shape metadata (position, size, text, and styling).", + "description": "Normal shape metadata.", "properties": { - "begin_arrow_style": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arrow style enum for the start of a connector.", - "title": "Begin Arrow Style" - }, - "begin_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", - "title": "Begin Id" - }, - "direction": { - "anyOf": [ - { - "enum": [ - "E", - "SE", - "S", - "SW", - "W", - "NW", - "N", - "NE" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Connector direction (compass heading).", - "title": "Direction" - }, - "end_arrow_style": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arrow style enum for the end of a connector.", - "title": "End Arrow Style" - }, - "end_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", - "title": "End Id" - }, "h": { "anyOf": [ { @@ -103,6 +28,13 @@ "description": "Sequential shape id within the sheet (if applicable).", "title": "Id" }, + "kind": { + "const": "shape", + "default": "shape", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, "l": { "description": "Left offset (Excel units).", "title": "L", diff --git a/schemas/sheet.json b/schemas/sheet.json index fff9dfc..9ea5497 100644 --- a/schemas/sheet.json +++ b/schemas/sheet.json @@ -1,5 +1,166 @@ { "$defs": { + "Arrow": { + "description": "Connector shape metadata.", + "properties": { + "begin_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the start of a connector.", + "title": "Begin Arrow Style" + }, + "begin_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", + "title": "Begin Id" + }, + "direction": { + "anyOf": [ + { + "enum": [ + "E", + "SE", + "S", + "SW", + "W", + "NW", + "N", + "NE" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Connector direction (compass heading).", + "title": "Direction" + }, + "end_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the end of a connector.", + "title": "End Arrow Style" + }, + "end_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", + "title": "End Id" + }, + "h": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape height (None if unknown).", + "title": "H" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" + }, + "kind": { + "const": "arrow", + "default": "arrow", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "rotation": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Rotation angle in degrees.", + "title": "Rotation" + }, + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "w": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t" + ], + "title": "Arrow", + "type": "object" + }, "CellRow": { "description": "A single row of cells with optional hyperlinks.", "properties": { @@ -246,9 +407,9 @@ "type": "object" }, "Shape": { - "description": "Shape metadata (position, size, text, and styling).", + "description": "Normal shape metadata.", "properties": { - "begin_arrow_style": { + "h": { "anyOf": [ { "type": "integer" @@ -258,10 +419,10 @@ } ], "default": null, - "description": "Arrow style enum for the start of a connector.", - "title": "Begin Arrow Style" + "description": "Shape height (None if unknown).", + "title": "H" }, - "begin_id": { + "id": { "anyOf": [ { "type": "integer" @@ -271,46 +432,58 @@ } ], "default": null, - "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", - "title": "Begin Id" + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" }, - "direction": { + "kind": { + "const": "shape", + "default": "shape", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "rotation": { "anyOf": [ { - "enum": [ - "E", - "SE", - "S", - "SW", - "W", - "NW", - "N", - "NE" - ], - "type": "string" + "type": "number" }, { "type": "null" } ], "default": null, - "description": "Connector direction (compass heading).", - "title": "Direction" + "description": "Rotation angle in degrees.", + "title": "Rotation" }, - "end_arrow_style": { + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "type": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], "default": null, - "description": "Arrow style enum for the end of a connector.", - "title": "End Arrow Style" + "description": "Excel shape type name.", + "title": "Type" }, - "end_id": { + "w": { "anyOf": [ { "type": "integer" @@ -320,9 +493,21 @@ } ], "default": null, - "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", - "title": "End Id" - }, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t" + ], + "title": "Shape", + "type": "object" + }, + "SmartArt": { + "description": "SmartArt shape metadata with nested nodes.", + "properties": { "h": { "anyOf": [ { @@ -349,11 +534,31 @@ "description": "Sequential shape id within the sheet (if applicable).", "title": "Id" }, + "kind": { + "const": "smartart", + "default": "smartart", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, "l": { "description": "Left offset (Excel units).", "title": "L", "type": "integer" }, + "layout": { + "description": "SmartArt layout name.", + "title": "Layout", + "type": "string" + }, + "nodes": { + "description": "Root nodes of SmartArt tree.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Nodes", + "type": "array" + }, "rotation": { "anyOf": [ { @@ -377,19 +582,6 @@ "title": "Text", "type": "string" }, - "type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Excel shape type name.", - "title": "Type" - }, "w": { "anyOf": [ { @@ -407,9 +599,33 @@ "required": [ "text", "l", - "t" + "t", + "layout" ], - "title": "Shape", + "title": "SmartArt", + "type": "object" + }, + "SmartArtNode": { + "description": "Node of SmartArt hierarchy.", + "properties": { + "kids": { + "description": "Child nodes.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Kids", + "type": "array" + }, + "text": { + "description": "Visible text for the node.", + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "SmartArtNode", "type": "object" } }, @@ -472,7 +688,17 @@ "shapes": { "description": "Shapes detected on the sheet.", "items": { - "$ref": "#/$defs/Shape" + "anyOf": [ + { + "$ref": "#/$defs/Shape" + }, + { + "$ref": "#/$defs/Arrow" + }, + { + "$ref": "#/$defs/SmartArt" + } + ] }, "title": "Shapes", "type": "array" diff --git a/schemas/workbook.json b/schemas/workbook.json index 4fac8d1..12ab273 100644 --- a/schemas/workbook.json +++ b/schemas/workbook.json @@ -1,5 +1,166 @@ { "$defs": { + "Arrow": { + "description": "Connector shape metadata.", + "properties": { + "begin_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the start of a connector.", + "title": "Begin Arrow Style" + }, + "begin_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", + "title": "Begin Id" + }, + "direction": { + "anyOf": [ + { + "enum": [ + "E", + "SE", + "S", + "SW", + "W", + "NW", + "N", + "NE" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Connector direction (compass heading).", + "title": "Direction" + }, + "end_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the end of a connector.", + "title": "End Arrow Style" + }, + "end_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", + "title": "End Id" + }, + "h": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape height (None if unknown).", + "title": "H" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" + }, + "kind": { + "const": "arrow", + "default": "arrow", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "rotation": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Rotation angle in degrees.", + "title": "Rotation" + }, + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "w": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t" + ], + "title": "Arrow", + "type": "object" + }, "CellRow": { "description": "A single row of cells with optional hyperlinks.", "properties": { @@ -246,83 +407,8 @@ "type": "object" }, "Shape": { - "description": "Shape metadata (position, size, text, and styling).", + "description": "Normal shape metadata.", "properties": { - "begin_arrow_style": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arrow style enum for the start of a connector.", - "title": "Begin Arrow Style" - }, - "begin_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", - "title": "Begin Id" - }, - "direction": { - "anyOf": [ - { - "enum": [ - "E", - "SE", - "S", - "SW", - "W", - "NW", - "N", - "NE" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Connector direction (compass heading).", - "title": "Direction" - }, - "end_arrow_style": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arrow style enum for the end of a connector.", - "title": "End Arrow Style" - }, - "end_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", - "title": "End Id" - }, "h": { "anyOf": [ { @@ -349,6 +435,13 @@ "description": "Sequential shape id within the sheet (if applicable).", "title": "Id" }, + "kind": { + "const": "shape", + "default": "shape", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, "l": { "description": "Left offset (Excel units).", "title": "L", @@ -471,7 +564,17 @@ "shapes": { "description": "Shapes detected on the sheet.", "items": { - "$ref": "#/$defs/Shape" + "anyOf": [ + { + "$ref": "#/$defs/Shape" + }, + { + "$ref": "#/$defs/Arrow" + }, + { + "$ref": "#/$defs/SmartArt" + } + ] }, "title": "Shapes", "type": "array" @@ -487,6 +590,129 @@ }, "title": "SheetData", "type": "object" + }, + "SmartArt": { + "description": "SmartArt shape metadata with nested nodes.", + "properties": { + "h": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape height (None if unknown).", + "title": "H" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" + }, + "kind": { + "const": "smartart", + "default": "smartart", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "layout": { + "description": "SmartArt layout name.", + "title": "Layout", + "type": "string" + }, + "nodes": { + "description": "Root nodes of SmartArt tree.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Nodes", + "type": "array" + }, + "rotation": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Rotation angle in degrees.", + "title": "Rotation" + }, + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "w": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t", + "layout" + ], + "title": "SmartArt", + "type": "object" + }, + "SmartArtNode": { + "description": "Node of SmartArt hierarchy.", + "properties": { + "kids": { + "description": "Child nodes.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Kids", + "type": "array" + }, + "text": { + "description": "Visible text for the node.", + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "SmartArtNode", + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", From df041954befde5a80dfb7adea005f8c53dbbfd2e Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 18:37:28 +0900 Subject: [PATCH 12/14] feat: Enhance Excel extraction capabilities with SmartArt and Arrow support - Updated README and documentation to reflect the addition of SmartArt and Arrow extraction features. - Introduced new schemas for Arrow, SmartArt, and SmartArtNode to support structured data representation. - Refactored JSON schema generation script to include new models. - Improved extraction logic to handle SmartArt layouts and nested nodes. - Enhanced output formats to include detailed metadata for shapes, arrows, and SmartArt. --- README.ja.md | 6 +- README.md | 6 +- docs/README.en.md | 8 +- docs/README.ja.md | 8 +- docs/agents/CODE_REVIEW.md | 779 -------------------------------- docs/agents/EXCEL_EXTRACTION.md | 5 +- docs/agents/OVERVIEW.md | 4 +- docs/concept.md | 2 +- docs/schemas.md | 3 + schemas/arrow.json | 162 +++++++ schemas/smartart.json | 126 ++++++ schemas/smartart_node.json | 29 ++ scripts/gen_json_schema.py | 6 + 13 files changed, 346 insertions(+), 798 deletions(-) create mode 100644 schemas/arrow.json create mode 100644 schemas/smartart.json create mode 100644 schemas/smartart_node.json diff --git a/README.ja.md b/README.ja.md index 0d3b85c..6ed7e01 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,12 +4,12 @@ ![ExStruct Image](/docs/assets/icon.webp) -ExStruct は Excel ワークブックを読み取り、構造化データ(セル・テーブル候補・図形・チャート・印刷範囲ビュー)をデフォルトで JSON に出力します。必要に応じて YAML/TOON も選択でき、COM/Excel 環境ではリッチ抽出、非 COM 環境ではセル+テーブル候補+印刷範囲へのフォールバックで安全に動作します。LLM/RAG 向けに検出ヒューリスティックや出力モードを調整可能です。 +ExStruct は Excel ワークブックを読み取り、構造化データ(セル・テーブル候補・図形・チャート・SmartArt・印刷範囲ビュー)をデフォルトで JSON に出力します。必要に応じて YAML/TOON も選択でき、COM/Excel 環境ではリッチ抽出、非 COM 環境ではセル+テーブル候補+印刷範囲へのフォールバックで安全に動作します。LLM/RAG 向けに検出ヒューリスティックや出力モードを調整可能です。 ## 主な特徴 -- **Excel → 構造化 JSON**: セル、図形、チャート、テーブル候補、印刷範囲/自動改ページ範囲(PrintArea/PrintAreaView)をシート単位・範囲単位で出力。 -- **出力モード**: `light`(セル+テーブル候補のみ)、`standard`(テキスト付き図形+矢印、チャート)、`verbose`(全図形を幅高さ付きで出力、セルのハイパーリンクも出力)。 +- **Excel → 構造化 JSON**: セル、図形、チャート、SmartArt、テーブル候補、印刷範囲/自動改ページ範囲(PrintArea/PrintAreaView)をシート単位・範囲単位で出力。 +- **出力モード**: `light`(セル+テーブル候補のみ)、`standard`(テキスト付き図形+矢印、チャート、SmartArt)、`verbose`(全図形を幅高さ付きで出力、セルのハイパーリンクも出力)。 - **フォーマット**: JSON(デフォルトはコンパクト、`--pretty` で整形)、YAML、TOON(任意依存)。 - **テーブル検出のチューニング**: API でヒューリスティックを動的に変更可能。 - **ハイパーリンク抽出**: `verbose` モード(または `include_cell_links=True` 指定)でセルのリンクを `links` に出力。 diff --git a/README.md b/README.md index 455f954..b1d4230 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ ![ExStruct Image](/docs/assets/icon.webp) -ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. +ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, smartart,print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. [日本版 README](README.ja.md) ## Features -- **Excel → Structured JSON**: cells, shapes, charts, table candidates, print areas/views, and auto page-break areas per sheet. -- **Output modes**: `light` (cells + table candidates + print areas; no COM, shapes/charts empty), `standard` (texted shapes + arrows, charts, print areas), `verbose` (all shapes with width/height, charts with size, print areas). Verbose also emits cell hyperlinks and `colors_map`. Size output is flag-controlled. +- **Excel → Structured JSON**: cells, shapes, charts, smartart, table candidates, print areas/views, and auto page-break areas per sheet. +- **Output modes**: `light` (cells + table candidates + print areas; no COM, shapes/charts empty), `standard` (texted shapes + arrows, charts, smartart, print areas), `verbose` (all shapes with width/height, charts with size, print areas). Verbose also emits cell hyperlinks and `colors_map`. Size output is flag-controlled. - **Auto page-break export (COM only)**: capture Excel-computed auto page breaks and write per-area JSON/YAML/TOON when requested (CLI option appears only when COM is available). - **Formats**: JSON (compact by default, `--pretty` available), YAML, TOON (optional dependencies). - **Table detection tuning**: adjust heuristics at runtime via API. diff --git a/docs/README.en.md b/docs/README.en.md index 68e3e86..45f3266 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -4,12 +4,14 @@ ![ExStruct Image](assets/icon.webp) -ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. +ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, smartart,print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. + +[日本版 README](README.ja.md) ## Features -- **Excel → Structured JSON**: cells, shapes, charts, table candidates, print areas/views, and auto page-break areas per sheet. -- **Output modes**: `light` (cells + table candidates + print areas; no COM, shapes/charts empty), `standard` (texted shapes + arrows, charts, print areas), `verbose` (all shapes with width/height, charts with size, print areas). Verbose also emits cell hyperlinks and `colors_map`. Size output is flag-controlled. +- **Excel → Structured JSON**: cells, shapes, charts, smartart, table candidates, print areas/views, and auto page-break areas per sheet. +- **Output modes**: `light` (cells + table candidates + print areas; no COM, shapes/charts empty), `standard` (texted shapes + arrows, charts, smartart, print areas), `verbose` (all shapes with width/height, charts with size, print areas). Verbose also emits cell hyperlinks and `colors_map`. Size output is flag-controlled. - **Auto page-break export (COM only)**: capture Excel-computed auto page breaks and write per-area JSON/YAML/TOON when requested (CLI option appears only when COM is available). - **Formats**: JSON (compact by default, `--pretty` available), YAML, TOON (optional dependencies). - **Table detection tuning**: adjust heuristics at runtime via API. diff --git a/docs/README.ja.md b/docs/README.ja.md index ad3763a..d3e2676 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -4,14 +4,12 @@ ![ExStruct Image](assets/icon.webp) -ExStruct は Excel ワークブックを読み取り、構造化データ(セル・テーブル候補・図形・チャート・印刷範囲ビュー)をデフォルトで JSON に出力します。必要に応じて YAML/TOON も選択でき、COM/Excel 環境ではリッチ抽出、非 COM 環境ではセル+テーブル候補+印刷範囲へのフォールバックで安全に動作します。LLM/RAG 向けに検出ヒューリスティックや出力モードを調整可能です。 - -[English README](README.en.md) +ExStruct は Excel ワークブックを読み取り、構造化データ(セル・テーブル候補・図形・チャート・SmartArt・印刷範囲ビュー)をデフォルトで JSON に出力します。必要に応じて YAML/TOON も選択でき、COM/Excel 環境ではリッチ抽出、非 COM 環境ではセル+テーブル候補+印刷範囲へのフォールバックで安全に動作します。LLM/RAG 向けに検出ヒューリスティックや出力モードを調整可能です。 ## 主な特徴 -- **Excel → 構造化 JSON**: セル、図形、チャート、テーブル候補、印刷範囲/自動改ページ範囲(PrintArea/PrintAreaView)をシート単位・範囲単位で出力。 -- **出力モード**: `light`(セル+テーブル候補のみ)、`standard`(テキスト付き図形+矢印、チャート)、`verbose`(全図形を幅高さ付きで出力、セルのハイパーリンクも出力)。 +- **Excel → 構造化 JSON**: セル、図形、チャート、SmartArt、テーブル候補、印刷範囲/自動改ページ範囲(PrintArea/PrintAreaView)をシート単位・範囲単位で出力。 +- **出力モード**: `light`(セル+テーブル候補のみ)、`standard`(テキスト付き図形+矢印、チャート、SmartArt)、`verbose`(全図形を幅高さ付きで出力、セルのハイパーリンクも出力)。 - **フォーマット**: JSON(デフォルトはコンパクト、`--pretty` で整形)、YAML、TOON(任意依存)。 - **テーブル検出のチューニング**: API でヒューリスティックを動的に変更可能。 - **ハイパーリンク抽出**: `verbose` モード(または `include_cell_links=True` 指定)でセルのリンクを `links` に出力。 diff --git a/docs/agents/CODE_REVIEW.md b/docs/agents/CODE_REVIEW.md index 5053262..e69de29 100644 --- a/docs/agents/CODE_REVIEW.md +++ b/docs/agents/CODE_REVIEW.md @@ -1,779 +0,0 @@ -````md -**Actionable comments posted: 0** - -> [!CAUTION] -> Some comments are outside the diff and can’t be posted inline due to platform limitations. -> ->
-> ⚠️ Outside diff range comments (1)
-> ->
-> src/exstruct/io/__init__.py (1)
-> -> `74-82`: **Return `RangeBounds` directly instead of converting to tuple.** -> -> This wrapper function unpacks the Pydantic `RangeBounds` model into a tuple, which violates the coding guideline: "Do not return dictionaries or tuples; always use Pydantic BaseModel for structured data." Callers should access bounds via the model's fields (`bounds.r1`, `bounds.c1`, etc.) to preserve type safety and semantic clarity. -> -> As per coding guidelines, structured data should be returned as Pydantic models, not tuples. -> ->
-> 🔎 Proposed refactor to eliminate tuple conversion -> -> **Option 1: Remove the wrapper entirely and use `parse_range_zero_based` directly** -> -> Update callers (e.g., line 129) to use the model fields: -> -> ```diff -> def _filter_table_candidates_to_area( -> table_candidates: list[str], area: PrintArea -> ) -> list[str]: -> filtered: list[str] = [] -> for candidate in table_candidates: -> - bounds = _parse_range_zero_based(candidate) -> - if not bounds: -> + bounds = parse_range_zero_based(candidate) -> + if bounds is None: -> continue -> - r1, c1, r2, c2 = bounds -> + r1, c1, r2, c2 = bounds.r1, bounds.c1, bounds.r2, bounds.c2 -> r1 += 1 -> r2 += 1 -> if r1 >= area.r1 and r2 <= area.r2 and c1 >= area.c1 and c2 <= area.c2: -> filtered.append(candidate) -> return filtered -> ``` -> -> **Option 2: If the wrapper is needed, change return type to `RangeBounds | None`** -> -> ```diff -> -def _parse_range_zero_based(range_str: str) -> tuple[int, int, int, int] | None: -> - """ -> - Parse an Excel range string into zero-based (r1, c1, r2, c2) bounds. -> - Returns None on failure. -> - """ -> +def _parse_range_zero_based(range_str: str) -> RangeBounds | None: -> + """Parse an Excel range string into zero-based bounds. -> + -> + Args: -> + range_str: Excel range string (e.g., "Sheet1!A1:B2"). -> + -> + Returns: -> + RangeBounds in zero-based coordinates, or None on failure. -> + """ -> bounds = parse_range_zero_based(range_str) -> - if bounds is None: -> - return None -> - return (bounds.r1, bounds.c1, bounds.r2, bounds.c2) -> + return bounds -> ``` -> ->
-> ->
-> ->
- -
-♻️ Duplicate comments (3)
- -
-src/exstruct/core/pipeline.py (2)
- -`644-645`: **Previous review concern addressed.** - -The error message has been updated to use a generic "COM pipeline failed" message with `FallbackReason.COM_PIPELINE_FAILED`, addressing the earlier review comment about misleading error messages. - ---- - -`670-670`: **Previous review concern addressed.** - -The `reason` parameter is now properly used for logging, addressing the earlier review comment about the unused parameter. - -
-
-docs/agents/TEST_REQUIREMENTS.md (1)
- -`203-204`: **Previous review concern addressed.** - -The duplicate requirement IDs have been resolved by renaming them to `[PIPE-MOD-01]` and `[PIPE-MOD-02]`, making each requirement ID unique throughout the document. - -
- -
- -
-🧹 Nitpick comments (7)
- -
-src/exstruct/io/__init__.py (1)
- -`488-489`: **Consider renaming publicly exported functions to remove underscore prefix.** - -The functions `_require_yaml` and `_require_toon` are now part of the public API (exported in `__all__`), but their names start with an underscore, which by Python convention indicates private/internal functions. For a cleaner public API, consider renaming them to `require_yaml` and `require_toon` (without the underscore). - -
-🔎 Suggested changes for consistent public API naming - -In `src/exstruct/io/serialize.py`, rename the functions: - -```diff --def _require_yaml() -> ModuleType: -+def require_yaml() -> ModuleType: - """Ensure pyyaml is installed; otherwise raise with guidance.""" - ... - --def _require_toon() -> ModuleType: -+def require_toon() -> ModuleType: - """Ensure python-toon is installed; otherwise raise with guidance.""" - ... -``` - -In this file, update the import and export: - -```diff - from .serialize import ( - _FORMAT_HINTS, - _ensure_format_hint, -- _require_toon, -- _require_yaml, -+ require_toon, -+ require_yaml, - _serialize_payload_from_hint, - ) - - __all__ = [ - "dict_without_empty_values", - "save_as_json", - "save_as_yaml", - "save_as_toon", - "save_sheets", - "save_sheets_as_json", - "build_print_area_views", - "save_print_area_views", - "save_auto_page_break_views", - "serialize_workbook", -- "_require_yaml", -- "_require_toon", -+ "require_yaml", -+ "require_toon", - ] -``` - -
- -
-
-tests/test_backends.py (3)
- -`16-22`: **Add type hints to mock functions.** - -The fake functions should have explicit type hints for maintainability and mypy compliance. - -
-🔎 Proposed refactor - -```diff -- def fake_cells(_: Path) -> dict[str, list[object]]: -+ def fake_cells(file_path: Path) -> dict[str, list[object]]: - calls.append("cells") - return {} - -- def fake_cells_links(_: Path) -> dict[str, list[object]]: -+ def fake_cells_links(file_path: Path) -> dict[str, list[object]]: - calls.append("links") - return {} -``` - -
- -As per coding guidelines, avoid using `_` for actual parameters; use descriptive names with proper type hints. - ---- - -`43-44`: **Use explicit parameter names with type hints.** - -Replace generic `_` and `__` with descriptive parameter names for better readability. - -
-🔎 Proposed refactor - -```diff -- def fake_detect(_: Path, __: str) -> list[str]: -+ def fake_detect(file_path: Path, sheet_name: str) -> list[str]: - raise RuntimeError("boom") -``` - -
- -As per coding guidelines, use descriptive parameter names. - ---- - -`58-59`: **Use explicit type hints instead of generic object.** - -The mock function should use proper type signatures for clarity. - -
-🔎 Proposed refactor - -```diff -- def fake_colors_map(*_: object, **__: object) -> object: -+ def fake_colors_map( -+ workbook: object, -+ *, -+ include_default_background: bool, -+ ignore_colors: set[str] | None -+ ) -> object: - raise RuntimeError("boom") -``` - -
- -As per coding guidelines, provide explicit type hints for all parameters. - -
-
-src/exstruct/core/backends/openpyxl_backend.py (1)
- -`103-125`: **Consider adding a module-level docstring.** - -The file contains well-structured code with proper docstrings for classes and methods, but lacks a module-level docstring explaining the openpyxl backend's role in the extraction pipeline. - -
-🔎 Suggested addition - -Add at the top of the file after imports: - -```python -"""Openpyxl-based backend for Excel workbook extraction. - -This module provides the OpenpyxlBackend class which uses the openpyxl library -to extract cells, print areas, color maps, and table candidates from Excel files. -It serves as the primary extraction backend with fallback support when COM is unavailable. -""" -``` - -
- -As per coding guidelines, adding comprehensive module documentation improves maintainability. - -
-
-src/exstruct/core/backends/com_backend.py (2)
- -`173-206`: **Consider extracting CSV parsing to a utility function for reusability.** - -The `_split_csv_respecting_quotes` function implements custom CSV parsing with quote handling. While the implementation is correct, this type of utility might be useful elsewhere in the codebase. - -Consider moving this to a shared utilities module if similar parsing is needed elsewhere, or documenting that Python's `csv` module with appropriate dialect settings could be an alternative. - ---- - -`1-206`: **Add module-level docstring for better documentation.** - -The file contains well-structured code with proper class and method docstrings, but lacks a module-level docstring explaining the COM backend's role. - -
-🔎 Suggested addition - -Add at the top after imports: - -```python -"""COM-based backend for Excel workbook extraction via xlwings. - -This module provides the ComBackend class which uses xlwings/COM to extract -print areas, color maps, and auto page breaks from Excel files. It complements -the OpenpyxlBackend and is used when COM automation is available and required -features (like auto page breaks) need native Excel API access. -""" -``` - -
- -As per coding guidelines, comprehensive module documentation improves maintainability. - -
- -
- -
-📜 Review details - -**Configuration used**: defaults - -**Review profile**: CHILL - -**Plan**: Pro - -
-📥 Commits - -Reviewing files that changed from the base of the PR and between 79d4bbb797caaa2641a3581e2d56059fc5a8cbf2 and 93b10e2d8b0e9e4e339f038cb215951dd4fdd7c2. - -
- -
-📒 Files selected for processing (20) - -- `.github/workflows/pytest.yml` -- `docs/agents/CODE_REVIEW.md` -- `docs/agents/DATA_MODEL.md` -- `docs/agents/EXCEL_EXTRACTION.md` -- `docs/agents/TEST_REQUIREMENTS.md` -- `src/exstruct/core/backends/com_backend.py` -- `src/exstruct/core/backends/openpyxl_backend.py` -- `src/exstruct/core/pipeline.py` -- `src/exstruct/errors.py` -- `src/exstruct/io/__init__.py` -- `src/exstruct/models/__init__.py` -- `tests/test_auto_page_breaks.py` -- `tests/test_backends.py` -- `tests/test_engine.py` -- `tests/test_integrate_raw_data.py` -- `tests/test_modeling.py` -- `tests/test_models_validation.py` -- `tests/test_pipeline.py` -- `tests/test_print_area_views.py` -- `tests/test_print_areas_openpyxl.py` - -
- -
-🚧 Files skipped from review as they are similar to previous changes (4) - -- tests/test_pipeline.py -- tests/test_modeling.py -- tests/test_integrate_raw_data.py -- src/exstruct/errors.py - -
- -
-🧰 Additional context used - -
-📓 Path-based instructions (1) - -
-**/*.py - -**📄 CodeRabbit inference engine (AGENTS.md)** - -> `**/*.py`: Add type hints to all function and method arguments and return values (mypy strict compliance) -> Use `Any` type only at external library boundaries (xlwings, pandas, numpy, etc.) -> Return Pydantic BaseModel instances instead of dictionaries or tuples for structured data -> Ensure each function has a single responsibility (cyclomatic complexity should not exceed 12) -> Organize imports in this order: (1) standard library, (2) third-party packages, (3) exstruct internal modules -> Use Google-style docstrings for all functions and classes -> Do not depend on internal structures of external libraries (xlwings, pandas, numpy); normalize external data to Pydantic models at boundaries -> Avoid writing God Functions (large, single-responsibility-violating functions) -> Avoid writing God Objects (classes with too many responsibilities) -> Avoid excessive nesting and deep conditional branching -> Do not return dictionaries or tuples; always use Pydantic BaseModel for structured data -> Ensure code passes mypy strict mode with zero errors -> Ensure code passes Ruff checks (E, W, F, I, B, UP, N, C90) with zero errors -> Avoid circular dependencies between modules -> AI should propose automatic function splitting when functions are too complex -> AI should propose additional Pydantic models when data structures are insufficient -> AI should automatically fix and organize imports when they are inconsistent -> AI should improve docstrings when they are incomplete or insufficient - -Files: - -- `tests/test_print_areas_openpyxl.py` -- `tests/test_backends.py` -- `src/exstruct/core/backends/openpyxl_backend.py` -- `src/exstruct/io/__init__.py` -- `src/exstruct/core/backends/com_backend.py` -- `tests/test_print_area_views.py` -- `tests/test_engine.py` -- `tests/test_auto_page_breaks.py` -- `src/exstruct/core/pipeline.py` -- `tests/test_models_validation.py` -- `src/exstruct/models/__init__.py` - -
- -
-🧠 Learnings (1) - -
-📚 Learning: 2025-12-21T10:55:22.238Z - -``` -Learnt from: CR -Repo: harumiWeb/exstruct PR: 0 -File: AGENTS.md:0-0 -Timestamp: 2025-12-21T10:55:22.238Z -Learning: Applies to **/*.py : Do not depend on internal structures of external libraries (xlwings, pandas, numpy); normalize external data to Pydantic models at boundaries -``` - -**Applied to files:** - -- `src/exstruct/io/__init__.py` - -
- -
-🧬 Code graph analysis (6) - -
-tests/test_backends.py (3)
- -
-src/exstruct/core/backends/com_backend.py (3) - -- `ComBackend` (18-135) -- `extract_colors_map` (51-74) -- `extract_print_areas` (27-49) - -
-
-src/exstruct/core/backends/openpyxl_backend.py (5) - -- `OpenpyxlBackend` (23-100) -- `extract_cells` (32-45) -- `detect_tables` (88-100) -- `extract_colors_map` (64-86) -- `extract_print_areas` (47-62) - -
-
-src/exstruct/core/ranges.py (1) - -- `parse_range_zero_based` (24-47) - -
- -
-
-src/exstruct/core/backends/openpyxl_backend.py (3)
- -
-src/exstruct/models/__init__.py (1) - -- `PrintArea` (97-103) - -
-
-src/exstruct/core/ranges.py (1) - -- `parse_range_zero_based` (24-47) - -
-
-src/exstruct/core/workbook.py (1) - -- `openpyxl_workbook` (14-53) - -
- -
-
-src/exstruct/io/__init__.py (3)
- -
-src/exstruct/core/ranges.py (1) - -- `parse_range_zero_based` (24-47) - -
-
-src/exstruct/errors.py (2) - -- `OutputError` (32-33) -- `SerializationError` (20-21) - -
-
-src/exstruct/io/serialize.py (4) - -- `_ensure_format_hint` (28-49) -- `_require_toon` (104-112) -- `_require_yaml` (93-101) -- `_serialize_payload_from_hint` (52-90) - -
- -
-
-tests/test_print_area_views.py (1)
- -
-src/exstruct/models/__init__.py (2) - -- `CellRow` (50-59) -- `PrintArea` (97-103) - -
- -
-
-tests/test_auto_page_breaks.py (1)
- -
-src/exstruct/models/__init__.py (1) - -- `PrintArea` (97-103) - -
- -
-
-tests/test_models_validation.py (1)
- -
-src/exstruct/models/__init__.py (1) - -- `CellRow` (50-59) - -
- -
- -
-🪛 GitHub Check: Codacy Static Code Analysis - -
-src/exstruct/core/backends/com_backend.py - -[warning] 37-37: src/exstruct/core/backends/com_backend.py#L37 -Try, Except, Continue detected. - ---- - -[warning] 132-132: src/exstruct/core/backends/com_backend.py#L132 -Try, Except, Pass detected. - -
- -
- -
- -
-⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2) - -- GitHub Check: Codacy Static Code Analysis -- GitHub Check: test (windows-latest, 3.11) - -
- -
-🔇 Additional comments (24)
- -
-docs/agents/DATA_MODEL.md (2)
- -`96-96`: **Clear coordinate semantics for PrintArea rows—good clarification.** - -Adding "(1-based, inclusive)" to `r1` and `r2` aligns with the existing `c1` and `c2` documentation and makes the row coordinate interpretation explicit. This improves clarity for API consumers. - -Also applies to: 98-98 - ---- - -`140-140`: **Documentation for `colors_map` coordinate system is correct and matches implementation.** - -The codebase confirms that `colors_map` uses (row=1-based, col=0-based) coordinates as documented. Both extraction paths—openpyxl (`cell.row, cell.col_idx - 1`) and COM (`row, col - 1`)—apply the same logic: rows are preserved as 1-based, columns are converted from 1-based to 0-based. Test coverage validates this with `{"#FFFFFF": [(1, 0)]}`, confirming row 1 with column 0. - -
-
-src/exstruct/io/__init__.py (3)
- -`8-18`: **Well done removing the OpenPyXL dependency!** - -The refactor successfully eliminates direct dependency on `openpyxl.utils.cell.range_boundaries` by using the internal `parse_range_zero_based` function, and centralizes serialization logic through the new helpers from `.serialize`. This aligns with the coding guideline to avoid depending on internal structures of external libraries. - -Based on learnings, this change properly normalizes external data to Pydantic models at boundaries. - ---- - -`283-288`: **Excellent serialization refactoring!** - -The centralization of format validation and serialization through `_ensure_format_hint` and `_serialize_payload_from_hint` eliminates code duplication and provides a single source of truth for format handling. This improves maintainability and consistency across all export functions. - -Also applies to: 315-318, 341-346, 374-377, 393-402, 429-432, 449-454, 469-471 - ---- - -`133-134`: **Row index adjustments correctly implement 1-based row coordinates.** - -The adjustments on lines 133-134 (adding 1 to `r1` and `r2`) and lines 148, 150 (subtracting 1 from `area.r1` in pixel calculations) correctly convert between the 0-based coordinates returned by `parse_range_zero_based` and the 1-based row coordinates used in `PrintArea`, as documented in the PR objectives. - -Also applies to: 148-148, 150-150 - -
-
-tests/test_models_validation.py (1)
- -`19-19`: **LGTM: Coordinate system alignment.** - -The update from `r=0` to `r=1` correctly reflects the 1-based row indexing documented in the `CellRow` model definition. - -
-
-tests/test_auto_page_breaks.py (1)
- -`47-47`: **LGTM: Coordinate system alignment.** - -The updated PrintArea coordinates correctly reflect the 1-based row indexing (r1=1, r2=2) and 0-based column indexing (c1=0, c2=1) documented in the PrintArea model. - -
-
-.github/workflows/pytest.yml (1)
- -`34-46`: **LGTM: Coverage collection and Codecov integration properly configured.** - -The pytest coverage flags and Codecov upload step are correctly implemented. The Codecov action is now properly pinned to a full commit SHA, addressing the previous security concern. - -
-
-src/exstruct/models/__init__.py (3)
- -`14-16`: **LGTM: Field definition reflow.** - -The Shape.id field definition has been reflowed for readability while maintaining the same semantic meaning. - ---- - -`101-103`: **LGTM: Coordinate system documentation clarified.** - -The field descriptions now explicitly document that columns use 0-based indexing, improving clarity for API consumers. - ---- - -`129-132`: **LGTM: colors_map coordinate semantics documented.** - -The description now explicitly clarifies that rows are 1-based and columns are 0-based, which is essential for correct usage of the colors_map field. - -
-
-docs/agents/EXCEL_EXTRACTION.md (1)
- -`1-66`: **LGTM: Comprehensive pipeline documentation.** - -This documentation provides clear guidance on the extraction pipeline architecture, coordinate system semantics, modes, and fallback behavior. The coordinate system clarification (rows 1-based, columns 0-based) is consistent with the code changes throughout the PR. - -
-
-src/exstruct/core/pipeline.py (1)
- -`1-696`: **LGTM: Pipeline architecture with proper type safety.** - -The pipeline module demonstrates excellent adherence to coding guidelines: - -- Complete type hints on all functions and parameters -- Google-style docstrings throughout -- Immutable dataclasses for pipeline configuration and state -- Well-organized imports (stdlib → third-party → internal) -- Clear separation of pre-COM and COM extraction steps - -The architecture provides a solid foundation for the extraction workflow with explicit fallback handling and state tracking. - -
-
-docs/agents/TEST_REQUIREMENTS.md (1)
- -`1-228`: **LGTM: Comprehensive test requirements specification.** - -The test requirements document provides thorough coverage of functional, non-functional, and integration requirements. The organization by category (pipeline, backend, ranges, etc.) aligns well with the modular architecture introduced in this PR. - -
-
-tests/test_print_areas_openpyxl.py (1)
- -`26-26`: **LGTM: Coordinate system alignment.** - -The assertion now correctly expects 1-based row coordinates (r1=1, r2=2) and 0-based column coordinates (c1=0, c2=1), consistent with the PrintArea model definition and the broader coordinate system updates in this PR. - -
-
-tests/test_print_area_views.py (1)
- -`39-46`: **LGTM! Coordinate system update correctly applied.** - -The test data has been properly updated to reflect the documented coordinate convention (1-based rows, 0-based columns). The CellRow indices and PrintArea bounds are now consistent with the model definitions. - -
-
-tests/test_backends.py (2)
- -`76-93`: **LGTM! Print area extraction test validates coordinate system.** - -The test correctly verifies that print areas extracted via openpyxl use 1-based row indexing (r1=1) and 0-based column indexing (c1=0), consistent with the PrintArea model definition. - ---- - -`95-101`: **LGTM! Range parsing test validates zero-based intermediate representation.** - -The test correctly verifies that `parse_range_zero_based` returns zero-based coordinates (r1=0, c1=0, r2=1, c2=1) for the range "Sheet1!A1:B2", which are then converted to the appropriate coordinate system by backend methods. - -
-
-src/exstruct/core/backends/openpyxl_backend.py (1)
- -`159-162`: **LGTM! Coordinate conversion correctly implemented.** - -The conversion from zero-based coordinates (from `_parse_print_area_range`) to the PrintArea model is now correct: - -- Rows (r1, r2): converted from 0-based to 1-based with `+ 1` -- Columns (c1, c2): kept as 0-based (no adjustment) - -This matches the PrintArea model definition where rows are 1-based and columns are 0-based. - -
-
-tests/test_engine.py (2)
- -`6-12`: **LGTM! Improved API design with nested option groups.** - -The introduction of `FilterOptions` and `DestinationOptions` improves the API's organization and clarity, grouping related settings together under `OutputOptions`. - ---- - -`65-65`: **LGTM! PrintArea coordinate updated correctly.** - -The PrintArea coordinates have been updated to use 1-based row indexing (r1=1) consistent with the model definition and broader coordinate system clarification in this PR. - -
-
-docs/agents/CODE_REVIEW.md (1)
- -`1-609`: **Documentation file - no code review needed.** - -This file contains example code review comments and AI agent prompts for documentation purposes. It does not contain production code requiring review. - -
-
-src/exstruct/core/backends/com_backend.py (2)
- -`45-48`: **LGTM! Coordinate conversion correctly implemented.** - -The conversion from zero-based coordinates (from `_parse_print_area_range`) to PrintArea is correct: - -- Rows (r1, r2): `+ 1` converts 0-based to 1-based -- Columns (c1, c2): no adjustment keeps 0-based - -This matches the PrintArea model definition. - ---- - -`119-125`: **LGTM! Coordinate conversion for COM values is correct.** - -The coordinate handling here is actually correct, contrary to the past review comment: - -- `rows[i]` and `cols[j]` are 1-based values from Excel COM -- Rows (r1, r2): used as-is since PrintArea expects 1-based rows ✓ -- Columns (c1, c2): `- 1` converts from 1-based COM to 0-based for PrintArea ✓ - -This is consistent with the PrintArea model where rows are 1-based and columns are 0-based. - -
- -
- -
- - -```` diff --git a/docs/agents/EXCEL_EXTRACTION.md b/docs/agents/EXCEL_EXTRACTION.md index fa0d361..92ed0a3 100644 --- a/docs/agents/EXCEL_EXTRACTION.md +++ b/docs/agents/EXCEL_EXTRACTION.md @@ -32,14 +32,15 @@ - openpyxl のテーブル定義 + 罫線クラスターを統合 - COM が使えない場合でも table_candidates を維持 -## Shapes +## Shapes / Arrows / SmartArt 抽出内容: -- Type / AutoShapeType の正規化 +- Type / AutoShapeType の正規化(`type` は Shape のみ) - Left/Top/Width/Height - TextFrame2.TextRange.Text - 矢印方向や接続情報 +- SmartArt の layout/nodes/kids(ネスト構造) ## Charts diff --git a/docs/agents/OVERVIEW.md b/docs/agents/OVERVIEW.md index 4e42d80..698dbd1 100644 --- a/docs/agents/OVERVIEW.md +++ b/docs/agents/OVERVIEW.md @@ -16,7 +16,7 @@ openpyxl と Excel COM(xlwings)を組み合わせ、LLM が扱いやすい - Cells(値/リンク/座標) - Tables(候補範囲) -- Shapes(位置/種類/テキスト/矢印) +- Shapes / Arrows / SmartArt(位置/テキスト/矢印/レイアウト) - Charts(Series/Axis/Type/Title) - Print Areas / Auto Page Breaks - Colors Map(条件付き書式を含む) @@ -24,7 +24,7 @@ openpyxl と Excel COM(xlwings)を組み合わせ、LLM が扱いやすい ## 利用例(概要) - `extract(path, mode="standard")` で WorkbookData を取得 -- `process_excel` でファイル出力やディレクトリ分割 +- `process_excel` でファイル出力やディレクトリ出力 - CLI で `exstruct file.xlsx --format json` を利用 ## ディレクトリ構成(概要) diff --git a/docs/concept.md b/docs/concept.md index f308ea8..cb9e739 100644 --- a/docs/concept.md +++ b/docs/concept.md @@ -108,7 +108,7 @@ For RAG and AI systems, this missing structure becomes a major bottleneck. ExStruct outputs a unified structure containing: - cells, rows, and sheets -- shapes and text blocks +- shapes, arrows, and SmartArt nodes (nested) - chart series and metadata - automatically detected table candidates - layout geometry (positions, sizes) diff --git a/docs/schemas.md b/docs/schemas.md index e14f467..39c3573 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -11,6 +11,9 @@ repository to access the raw files. - `schemas/sheet.json` — `SheetData` - `schemas/cell_row.json` — `CellRow` - `schemas/shape.json` — `Shape` +- `schemas/arrow.json` `Arrow` +- `schemas/smartart.json` `SmartArt` +- `schemas/smartart_node.json` `SmartArtNode` - `schemas/chart.json` — `Chart` - `schemas/chart_series.json` — `ChartSeries` - `schemas/print_area.json` — `PrintArea` diff --git a/schemas/arrow.json b/schemas/arrow.json new file mode 100644 index 0000000..d2ef8f1 --- /dev/null +++ b/schemas/arrow.json @@ -0,0 +1,162 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Connector shape metadata.", + "properties": { + "begin_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the start of a connector.", + "title": "Begin Arrow Style" + }, + "begin_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape).", + "title": "Begin Id" + }, + "direction": { + "anyOf": [ + { + "enum": [ + "E", + "SE", + "S", + "SW", + "W", + "NW", + "N", + "NE" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Connector direction (compass heading).", + "title": "Direction" + }, + "end_arrow_style": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arrow style enum for the end of a connector.", + "title": "End Arrow Style" + }, + "end_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape).", + "title": "End Id" + }, + "h": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape height (None if unknown).", + "title": "H" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" + }, + "kind": { + "const": "arrow", + "default": "arrow", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "rotation": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Rotation angle in degrees.", + "title": "Rotation" + }, + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "w": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t" + ], + "title": "Arrow", + "type": "object" +} \ No newline at end of file diff --git a/schemas/smartart.json b/schemas/smartart.json new file mode 100644 index 0000000..68d1cab --- /dev/null +++ b/schemas/smartart.json @@ -0,0 +1,126 @@ +{ + "$defs": { + "SmartArtNode": { + "description": "Node of SmartArt hierarchy.", + "properties": { + "kids": { + "description": "Child nodes.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Kids", + "type": "array" + }, + "text": { + "description": "Visible text for the node.", + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "SmartArtNode", + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "SmartArt shape metadata with nested nodes.", + "properties": { + "h": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape height (None if unknown).", + "title": "H" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sequential shape id within the sheet (if applicable).", + "title": "Id" + }, + "kind": { + "const": "smartart", + "default": "smartart", + "description": "Shape kind.", + "title": "Kind", + "type": "string" + }, + "l": { + "description": "Left offset (Excel units).", + "title": "L", + "type": "integer" + }, + "layout": { + "description": "SmartArt layout name.", + "title": "Layout", + "type": "string" + }, + "nodes": { + "description": "Root nodes of SmartArt tree.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Nodes", + "type": "array" + }, + "rotation": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Rotation angle in degrees.", + "title": "Rotation" + }, + "t": { + "description": "Top offset (Excel units).", + "title": "T", + "type": "integer" + }, + "text": { + "description": "Visible text content of the shape.", + "title": "Text", + "type": "string" + }, + "w": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Shape width (None if unknown).", + "title": "W" + } + }, + "required": [ + "text", + "l", + "t", + "layout" + ], + "title": "SmartArt", + "type": "object" +} \ No newline at end of file diff --git a/schemas/smartart_node.json b/schemas/smartart_node.json new file mode 100644 index 0000000..109b7b7 --- /dev/null +++ b/schemas/smartart_node.json @@ -0,0 +1,29 @@ +{ + "$defs": { + "SmartArtNode": { + "description": "Node of SmartArt hierarchy.", + "properties": { + "kids": { + "description": "Child nodes.", + "items": { + "$ref": "#/$defs/SmartArtNode" + }, + "title": "Kids", + "type": "array" + }, + "text": { + "description": "Visible text for the node.", + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "SmartArtNode", + "type": "object" + } + }, + "$ref": "#/$defs/SmartArtNode", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} \ No newline at end of file diff --git a/scripts/gen_json_schema.py b/scripts/gen_json_schema.py index b230b05..a848939 100644 --- a/scripts/gen_json_schema.py +++ b/scripts/gen_json_schema.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from exstruct.models import ( + Arrow, CellRow, Chart, ChartSeries, @@ -13,6 +14,8 @@ PrintAreaView, Shape, SheetData, + SmartArt, + SmartArtNode, WorkbookData, ) @@ -44,6 +47,9 @@ def main() -> int: "sheet": SheetData, "cell_row": CellRow, "shape": Shape, + "arrow": Arrow, + "smartart": SmartArt, + "smartart_node": SmartArtNode, "chart": Chart, "chart_series": ChartSeries, "print_area": PrintArea, From 6010edcf27cdb8cd4b8484ea148b323e0a2e8652 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 18:40:47 +0900 Subject: [PATCH 13/14] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E5=AE=9A?= =?UTF-8?q?=E7=BE=A9=E6=9B=B8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/TEST_REQUIREMENTS.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/agents/TEST_REQUIREMENTS.md b/docs/agents/TEST_REQUIREMENTS.md index 83cde0e..05dd053 100644 --- a/docs/agents/TEST_REQUIREMENTS.md +++ b/docs/agents/TEST_REQUIREMENTS.md @@ -1,6 +1,6 @@ # ExStruct テスト要件仕様書 -Version: 0.3 +Version: 0.4 Status: Required for Release ExStruct の全機能について、正式なテスト要件をまとめたドキュメントです。AI エージェント/人間開発者が自動テスト・手動テストを設計するための基盤とします。 @@ -51,6 +51,7 @@ ExStruct の全機能について、正式なテスト要件をまとめたド - [SHP-01] AutoShape の type を正規化 - [SHP-02] TextFrame を正しく取得 +- [SHP-02a] `type` は Shape のみ保持し、Arrow/SmartArt では出力しない - [SHP-03] サイズ `w`,`h` は取得できない場合のみ null - [SHP-04] グループ図形は展開方針を一貫させる - [SHP-05] 座標 `l`,`t` は整数で取得しズームの影響を受けない @@ -60,6 +61,13 @@ ExStruct の全機能について、正式なテスト要件をまとめたド - [SHP-11] テキストなし図形は text="" - [SHP-12] 複数段落のテキストも取得 +## 2.2.1 SmartArt 抽出 + +- [SHP-SA-01] SmartArt は `layout` を必須で出力する +- [SHP-SA-02] SmartArt のノードは `nodes` にネスト構造で出力する +- [SHP-SA-03] ノードの子は `kids` で表現する(level は出力しない) +- [SHP-SA-04] SmartArt が存在する場合は `kind="smartart"` で判別できる + ## 2.3 矢印方向推定 - [DIR-01] 0° ±22.5° → "E" From ac17a18a0bcc54b7b1fae6d387cf98d3307a9e3a Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 18:57:39 +0900 Subject: [PATCH 14/14] feat: Add SmartArt node level extraction and update tests --- README.md | 2 +- docs/README.en.md | 2 +- src/exstruct/core/shapes.py | 16 ++- tests/core/test_shapes_positions_dummy.py | 39 +++++- tests/core/test_shapes_smartart_utils.py | 147 ++++++++++++++++++++++ 5 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 tests/core/test_shapes_smartart_utils.py diff --git a/README.md b/README.md index b1d4230..11a456b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![ExStruct Image](/docs/assets/icon.webp) -ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, smartart,print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. +ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, smartart, print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. [日本版 README](README.ja.md) diff --git a/docs/README.en.md b/docs/README.en.md index 45f3266..5ae275f 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -4,7 +4,7 @@ ![ExStruct Image](assets/icon.webp) -ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, smartart,print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. +ExStruct reads Excel workbooks and outputs structured data (cells, table candidates, shapes, charts, smartart, print areas/views, auto page-break areas, hyperlinks) as JSON by default, with optional YAML/TOON formats. It targets both COM/Excel environments (rich extraction) and non-COM environments (cells + table candidates + print areas), with tunable detection heuristics and multiple output modes to fit LLM/RAG pipelines. [日本版 README](README.ja.md) diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index a33bbdc..1831937 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -178,9 +178,8 @@ def _collect_smartart_node_info( return nodes_info for node in all_nodes: - try: - level = int(node.Level) - except Exception: + level = _get_smartart_node_level(node) + if level is None: continue text = "" try: @@ -194,6 +193,14 @@ def _collect_smartart_node_info( return nodes_info +def _get_smartart_node_level(node: _SmartArtNodeLike) -> int | None: + """Return SmartArt node level or None when unavailable.""" + try: + return int(node.Level) + except Exception: + return None + + def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode]: """Build nested SmartArtNode roots from flat (level, text) tuples.""" roots: list[SmartArtNode] = [] @@ -256,6 +263,9 @@ def get_shapes_with_position( # noqa: C901 except Exception: text = "" + if mode == "light": + continue + has_smartart = _shape_has_smartart(shp) if not has_smartart and not _should_include_shape( text=text, diff --git a/tests/core/test_shapes_positions_dummy.py b/tests/core/test_shapes_positions_dummy.py index 071db23..999e70b 100644 --- a/tests/core/test_shapes_positions_dummy.py +++ b/tests/core/test_shapes_positions_dummy.py @@ -46,6 +46,27 @@ def Rotation(self) -> float: return self.rotation +@dataclass(frozen=True) +class _DummyApiSmartArt: + shape_type: int + + @property + def Type(self) -> int: + return self.shape_type + + @property + def AutoShapeType(self) -> int: + raise RuntimeError("AutoShapeType unavailable") + + @property + def HasSmartArt(self) -> bool: + return True + + @property + def SmartArt(self) -> object: + return object() + + @dataclass(frozen=True) class _DummyShape: name: str @@ -54,7 +75,7 @@ class _DummyShape: top: float width: float height: float - api: _DummyApi + api: object @dataclass(frozen=True) @@ -153,3 +174,19 @@ def test_get_shapes_with_position_verbose_includes_all_and_sizes() -> None: assert len(shapes) == 3 assert all(s.w is not None and s.h is not None for s in shapes) + + +def test_get_shapes_with_position_light_skips_smartart() -> None: + smartart_shape = _DummyShape( + name="SmartArt1", + text="sa", + left=10.0, + top=20.0, + width=100.0, + height=50.0, + api=_DummyApiSmartArt(shape_type=24), + ) + book = _DummyBook(sheets=[_DummySheet(name="Sheet1", shapes=[smartart_shape])]) + + result = get_shapes_with_position(book, mode="light") + assert result["Sheet1"] == [] diff --git a/tests/core/test_shapes_smartart_utils.py b/tests/core/test_shapes_smartart_utils.py new file mode 100644 index 0000000..e8c49b0 --- /dev/null +++ b/tests/core/test_shapes_smartart_utils.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +import xlwings as xw + +from exstruct.core import shapes as shapes_mod + + +@dataclass +class _DummyTextRange: + Text: str | None # noqa: N815 + + +@dataclass +class _DummyTextFrame: + HasText: bool # noqa: N815 + TextRange: _DummyTextRange # noqa: N815 + + +@dataclass +class _DummyNode: + Level: int # noqa: N815 + TextFrame2: _DummyTextFrame # noqa: N815 + + +@dataclass +class _DummyLayout: + Name: str | None # noqa: N815 + + +@dataclass +class _DummySmartArt: + AllNodes: list[_DummyNode] # noqa: N815 + Layout: object # noqa: N815 + + +@dataclass(frozen=True) +class _DummyApi: + HasSmartArt: bool # noqa: N815 + SmartArt: _DummySmartArt | None # noqa: N815 + + +@dataclass(frozen=True) +class _DummyApiRaises: + @property + def HasSmartArt(self) -> bool: # noqa: N802 + raise RuntimeError("HasSmartArt unavailable") + + +@dataclass(frozen=True) +class _DummyShape: + api_obj: object + + @property + def api(self) -> object: + return self.api_obj + + +@dataclass(frozen=True) +class _DummyShapeRaisesApi: + @property + def api(self) -> object: + raise RuntimeError("api unavailable") + + +def test_shape_has_smartart_true_false() -> None: + smartart = _DummySmartArt(AllNodes=[], Layout=_DummyLayout(Name="L")) + has = shapes_mod._shape_has_smartart( + cast( + xw.Shape, + _DummyShape(api_obj=_DummyApi(HasSmartArt=True, SmartArt=smartart)), + ) + ) + assert has is True + + has_false = shapes_mod._shape_has_smartart( + cast(xw.Shape, _DummyShape(api_obj=_DummyApi(HasSmartArt=False, SmartArt=None))) + ) + assert has_false is False + + +def test_shape_has_smartart_handles_exceptions() -> None: + has = shapes_mod._shape_has_smartart( + cast(xw.Shape, _DummyShape(api_obj=_DummyApiRaises())) + ) + assert has is False + + has_api_error = shapes_mod._shape_has_smartart( + cast(xw.Shape, _DummyShapeRaisesApi()) + ) + assert has_api_error is False + + +def test_get_smartart_layout_name() -> None: + assert shapes_mod._get_smartart_layout_name(None) == "Unknown" + smartart = _DummySmartArt(AllNodes=[], Layout=_DummyLayout(Name="Layout")) + assert ( + shapes_mod._get_smartart_layout_name(cast(shapes_mod._SmartArtLike, smartart)) + == "Layout" + ) + smartart_no_name = _DummySmartArt(AllNodes=[], Layout=_DummyLayout(Name=None)) + assert ( + shapes_mod._get_smartart_layout_name( + cast(shapes_mod._SmartArtLike, smartart_no_name) + ) + == "Unknown" + ) + + +def test_collect_smartart_node_info_and_tree() -> None: + nodes = [ + _DummyNode( + Level=1, + TextFrame2=_DummyTextFrame( + HasText=True, TextRange=_DummyTextRange(Text="root") + ), + ), + _DummyNode( + Level=2, + TextFrame2=_DummyTextFrame( + HasText=True, TextRange=_DummyTextRange(Text="child") + ), + ), + _DummyNode( + Level=1, + TextFrame2=_DummyTextFrame( + HasText=False, TextRange=_DummyTextRange(Text=None) + ), + ), + ] + smartart = _DummySmartArt(AllNodes=nodes, Layout=_DummyLayout(Name="L")) + info = shapes_mod._collect_smartart_node_info( + cast(shapes_mod._SmartArtLike, smartart) + ) + assert info == [(1, "root"), (2, "child"), (1, "")] + + roots = shapes_mod._extract_smartart_nodes(cast(shapes_mod._SmartArtLike, smartart)) + assert len(roots) == 2 + assert roots[0].text == "root" + assert roots[0].kids[0].text == "child" + assert roots[1].text == "" + + +def test_collect_smartart_node_info_none() -> None: + assert shapes_mod._collect_smartart_node_info(None) == []