exstruct 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exstruct/__init__.py +116 -0
- exstruct/cli/main.py +93 -0
- exstruct/core/__init__.py +0 -0
- exstruct/core/cells.py +950 -0
- exstruct/core/charts.py +229 -0
- exstruct/core/integrate.py +132 -0
- exstruct/core/shapes.py +212 -0
- exstruct/io/__init__.py +179 -0
- exstruct/models/__init__.py +51 -0
- exstruct/models/maps.py +335 -0
- exstruct/py.typed +0 -0
- exstruct/render/__init__.py +89 -0
- exstruct-0.1.0.dist-info/METADATA +154 -0
- exstruct-0.1.0.dist-info/RECORD +16 -0
- exstruct-0.1.0.dist-info/WHEEL +4 -0
- exstruct-0.1.0.dist-info/entry_points.txt +3 -0
exstruct/core/charts.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import xlwings as xw
|
|
7
|
+
|
|
8
|
+
from ..models import Chart, ChartSeries
|
|
9
|
+
from ..models.maps import XL_CHART_TYPE_MAP
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _extract_series_args_text(formula: str) -> Optional[str]:
|
|
15
|
+
"""Extract the outer argument text from '=SERIES(...)'; return None if unmatched."""
|
|
16
|
+
if not formula:
|
|
17
|
+
return None
|
|
18
|
+
s = formula.strip()
|
|
19
|
+
if not s.upper().startswith("=SERIES"):
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
open_idx = s.index("(", s.upper().index("=SERIES"))
|
|
23
|
+
except ValueError:
|
|
24
|
+
return None
|
|
25
|
+
depth_paren = 0
|
|
26
|
+
depth_brace = 0
|
|
27
|
+
in_str = False
|
|
28
|
+
i = open_idx + 1
|
|
29
|
+
start = i
|
|
30
|
+
while i < len(s):
|
|
31
|
+
ch = s[i]
|
|
32
|
+
if in_str:
|
|
33
|
+
if ch == '"':
|
|
34
|
+
if i + 1 < len(s) and s[i + 1] == '"':
|
|
35
|
+
i += 2
|
|
36
|
+
continue
|
|
37
|
+
else:
|
|
38
|
+
in_str = False
|
|
39
|
+
i += 1
|
|
40
|
+
continue
|
|
41
|
+
else:
|
|
42
|
+
i += 1
|
|
43
|
+
continue
|
|
44
|
+
else:
|
|
45
|
+
if ch == '"':
|
|
46
|
+
in_str = True
|
|
47
|
+
i += 1
|
|
48
|
+
continue
|
|
49
|
+
elif ch == "(":
|
|
50
|
+
depth_paren += 1
|
|
51
|
+
elif ch == ")":
|
|
52
|
+
if depth_paren == 0:
|
|
53
|
+
return s[start:i].strip()
|
|
54
|
+
depth_paren -= 1
|
|
55
|
+
elif ch == "{":
|
|
56
|
+
depth_brace += 1
|
|
57
|
+
elif ch == "}":
|
|
58
|
+
if depth_brace > 0:
|
|
59
|
+
depth_brace -= 1
|
|
60
|
+
i += 1
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _split_top_level_args(args_text: str) -> List[str]:
|
|
65
|
+
"""Split SERIES arguments at top-level separators (',' or ';')."""
|
|
66
|
+
if args_text is None:
|
|
67
|
+
return []
|
|
68
|
+
use_semicolon = (";" in args_text) and ("," not in args_text.split('"')[0])
|
|
69
|
+
sep_chars = (";",) if use_semicolon else (",",)
|
|
70
|
+
args: List[str] = []
|
|
71
|
+
buf: List[str] = []
|
|
72
|
+
depth_paren = 0
|
|
73
|
+
depth_brace = 0
|
|
74
|
+
in_str = False
|
|
75
|
+
i = 0
|
|
76
|
+
while i < len(args_text):
|
|
77
|
+
ch = args_text[i]
|
|
78
|
+
if in_str:
|
|
79
|
+
if ch == '"':
|
|
80
|
+
if i + 1 < len(args_text) and args_text[i + 1] == '"':
|
|
81
|
+
buf.append('"')
|
|
82
|
+
i += 2
|
|
83
|
+
continue
|
|
84
|
+
else:
|
|
85
|
+
in_str = False
|
|
86
|
+
i += 1
|
|
87
|
+
continue
|
|
88
|
+
else:
|
|
89
|
+
buf.append(ch)
|
|
90
|
+
i += 1
|
|
91
|
+
continue
|
|
92
|
+
else:
|
|
93
|
+
if ch == '"':
|
|
94
|
+
in_str = True
|
|
95
|
+
i += 1
|
|
96
|
+
continue
|
|
97
|
+
elif ch == "(":
|
|
98
|
+
depth_paren += 1
|
|
99
|
+
buf.append(ch)
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
elif ch == ")":
|
|
103
|
+
depth_paren = max(0, depth_paren - 1)
|
|
104
|
+
buf.append(ch)
|
|
105
|
+
i += 1
|
|
106
|
+
continue
|
|
107
|
+
elif ch == "{":
|
|
108
|
+
depth_brace += 1
|
|
109
|
+
buf.append(ch)
|
|
110
|
+
i += 1
|
|
111
|
+
continue
|
|
112
|
+
elif ch == "}":
|
|
113
|
+
depth_brace = max(0, depth_brace - 1)
|
|
114
|
+
buf.append(ch)
|
|
115
|
+
i += 1
|
|
116
|
+
continue
|
|
117
|
+
elif (ch in sep_chars) and depth_paren == 0 and depth_brace == 0:
|
|
118
|
+
args.append("".join(buf).strip())
|
|
119
|
+
buf = []
|
|
120
|
+
i += 1
|
|
121
|
+
continue
|
|
122
|
+
else:
|
|
123
|
+
buf.append(ch)
|
|
124
|
+
i += 1
|
|
125
|
+
continue
|
|
126
|
+
if buf or (args and args_text.endswith(sep_chars)):
|
|
127
|
+
args.append("".join(buf).strip())
|
|
128
|
+
return args
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _unquote_excel_string(s: Optional[str]) -> Optional[str]:
|
|
132
|
+
"""Decode Excel-style quoted string; return None if not quoted."""
|
|
133
|
+
if s is None:
|
|
134
|
+
return None
|
|
135
|
+
st = s.strip()
|
|
136
|
+
if len(st) >= 2 and st[0] == '"' and st[-1] == '"':
|
|
137
|
+
inner = st[1:-1]
|
|
138
|
+
return inner.replace('""', '"')
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_series_formula(formula: str) -> Optional[Dict[str, Optional[str]]]:
|
|
143
|
+
"""Parse =SERIES into a dict of references; return None on failure."""
|
|
144
|
+
args_text = _extract_series_args_text(formula)
|
|
145
|
+
if args_text is None:
|
|
146
|
+
return None
|
|
147
|
+
parts = _split_top_level_args(args_text)
|
|
148
|
+
name_part = parts[0].strip() if len(parts) >= 1 and parts[0].strip() != "" else None
|
|
149
|
+
x_part = parts[1].strip() if len(parts) >= 2 and parts[1].strip() != "" else None
|
|
150
|
+
y_part = parts[2].strip() if len(parts) >= 3 and parts[2].strip() != "" else None
|
|
151
|
+
plot_order_part = (
|
|
152
|
+
parts[3].strip() if len(parts) >= 4 and parts[3].strip() != "" else None
|
|
153
|
+
)
|
|
154
|
+
bubble_part = (
|
|
155
|
+
parts[4].strip() if len(parts) >= 5 and parts[4].strip() != "" else None
|
|
156
|
+
)
|
|
157
|
+
name_literal = _unquote_excel_string(name_part)
|
|
158
|
+
name_range = None if name_literal is not None else name_part
|
|
159
|
+
return {
|
|
160
|
+
"name_range": name_range,
|
|
161
|
+
"x_range": x_part,
|
|
162
|
+
"y_range": y_part,
|
|
163
|
+
"plot_order": plot_order_part,
|
|
164
|
+
"bubble_size_range": bubble_part,
|
|
165
|
+
"name_literal": name_literal,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_charts(sheet: xw.Sheet) -> List[Chart]:
|
|
170
|
+
"""Parse charts in a sheet into Chart models; failed charts carry an error field."""
|
|
171
|
+
charts: List[Chart] = []
|
|
172
|
+
for ch in sheet.charts:
|
|
173
|
+
series_list: List[ChartSeries] = []
|
|
174
|
+
y_axis_title: str = ""
|
|
175
|
+
y_axis_range: List[int] = []
|
|
176
|
+
chart_type_label: str = "unknown"
|
|
177
|
+
error: Optional[str] = None
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
chart_com = sheet.api.ChartObjects(ch.name).Chart
|
|
181
|
+
chart_type_num = chart_com.ChartType
|
|
182
|
+
chart_type_label = XL_CHART_TYPE_MAP.get(
|
|
183
|
+
chart_type_num, f"unknown_{chart_type_num}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
for s in chart_com.SeriesCollection():
|
|
187
|
+
parsed = parse_series_formula(getattr(s, "Formula", ""))
|
|
188
|
+
name_range = parsed["name_range"] if parsed else None
|
|
189
|
+
x_range = parsed["x_range"] if parsed else None
|
|
190
|
+
y_range = parsed["y_range"] if parsed else None
|
|
191
|
+
|
|
192
|
+
series_list.append(
|
|
193
|
+
ChartSeries(
|
|
194
|
+
name=s.Name,
|
|
195
|
+
name_range=name_range,
|
|
196
|
+
x_range=x_range,
|
|
197
|
+
y_range=y_range,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
y_axis = chart_com.Axes(2, 1)
|
|
203
|
+
if y_axis.HasTitle:
|
|
204
|
+
y_axis_title = y_axis.AxisTitle.Text
|
|
205
|
+
y_axis_range = [y_axis.MinimumScale, y_axis.MaximumScale]
|
|
206
|
+
except Exception:
|
|
207
|
+
y_axis_title = ""
|
|
208
|
+
y_axis_range = []
|
|
209
|
+
|
|
210
|
+
title = chart_com.ChartTitle.Text if chart_com.HasTitle else None
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.warning("Failed to parse chart; returning with error string.")
|
|
213
|
+
title = None
|
|
214
|
+
error = "Failed to build chart JSON structure"
|
|
215
|
+
|
|
216
|
+
charts.append(
|
|
217
|
+
Chart(
|
|
218
|
+
name=ch.name,
|
|
219
|
+
chart_type=chart_type_label,
|
|
220
|
+
title=title,
|
|
221
|
+
y_axis_title=y_axis_title,
|
|
222
|
+
y_axis_range=y_axis_range, # type: ignore
|
|
223
|
+
series=series_list,
|
|
224
|
+
l=int(ch.left),
|
|
225
|
+
t=int(ch.top),
|
|
226
|
+
error=error,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return charts
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, List, Literal
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import xlwings as xw
|
|
9
|
+
|
|
10
|
+
from ..models import CellRow, Shape, SheetData, WorkbookData
|
|
11
|
+
from .cells import (
|
|
12
|
+
detect_tables,
|
|
13
|
+
detect_tables_openpyxl,
|
|
14
|
+
extract_sheet_cells,
|
|
15
|
+
)
|
|
16
|
+
from .charts import get_charts
|
|
17
|
+
from .shapes import get_shapes_with_position
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
_ALLOWED_MODES: set[str] = {"light", "standard", "verbose"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_open_workbook(file_path: Path) -> xw.Book | None:
|
|
24
|
+
"""Return an existing workbook if already open in Excel; otherwise None."""
|
|
25
|
+
try:
|
|
26
|
+
for app in xw.apps:
|
|
27
|
+
for wb in app.books:
|
|
28
|
+
try:
|
|
29
|
+
if Path(wb.fullname).resolve() == file_path.resolve():
|
|
30
|
+
return wb
|
|
31
|
+
except Exception:
|
|
32
|
+
continue
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _open_workbook(file_path: Path) -> tuple[xw.Book, bool]:
|
|
39
|
+
"""
|
|
40
|
+
Open workbook:
|
|
41
|
+
- If already open, reuse and do not close Excel on exit.
|
|
42
|
+
- Otherwise create invisible Excel (visible=False) and close when done.
|
|
43
|
+
Returns (workbook, should_close_app).
|
|
44
|
+
"""
|
|
45
|
+
existing = _find_open_workbook(file_path)
|
|
46
|
+
if existing:
|
|
47
|
+
return existing, False
|
|
48
|
+
app = xw.App(add_book=False, visible=False)
|
|
49
|
+
wb = app.books.open(str(file_path))
|
|
50
|
+
return wb, True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def integrate_sheet_content(
|
|
54
|
+
cell_data: Dict[str, List[CellRow]],
|
|
55
|
+
shape_data: Dict[str, List[Shape]],
|
|
56
|
+
workbook: xw.Book,
|
|
57
|
+
mode: Literal["light", "standard", "verbose"] = "standard",
|
|
58
|
+
) -> Dict[str, SheetData]:
|
|
59
|
+
"""Integrate cells, shapes, charts, and tables into SheetData per sheet."""
|
|
60
|
+
result: Dict[str, SheetData] = {}
|
|
61
|
+
for sheet_name, rows in cell_data.items():
|
|
62
|
+
sheet_shapes = shape_data.get(sheet_name, [])
|
|
63
|
+
sheet = workbook.sheets[sheet_name]
|
|
64
|
+
|
|
65
|
+
sheet_model = SheetData(
|
|
66
|
+
rows=rows,
|
|
67
|
+
shapes=sheet_shapes,
|
|
68
|
+
charts=[] if mode == "light" else get_charts(sheet),
|
|
69
|
+
table_candidates=detect_tables(sheet),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
result[sheet_name] = sheet_model
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_workbook(
|
|
77
|
+
file_path: Path, mode: Literal["light", "standard", "verbose"] = "standard"
|
|
78
|
+
) -> WorkbookData:
|
|
79
|
+
"""Extract workbook and return WorkbookData; fallback to cells+tables if Excel COM is unavailable."""
|
|
80
|
+
if mode not in _ALLOWED_MODES:
|
|
81
|
+
raise ValueError(f"Unsupported mode: {mode}")
|
|
82
|
+
|
|
83
|
+
cell_data = extract_sheet_cells(file_path)
|
|
84
|
+
|
|
85
|
+
def _cells_and_tables_only(reason: str) -> WorkbookData:
|
|
86
|
+
sheets: Dict[str, SheetData] = {}
|
|
87
|
+
for sheet_name, rows in cell_data.items():
|
|
88
|
+
try:
|
|
89
|
+
tables = detect_tables_openpyxl(file_path, sheet_name)
|
|
90
|
+
except Exception:
|
|
91
|
+
tables = []
|
|
92
|
+
sheets[sheet_name] = SheetData(
|
|
93
|
+
rows=rows,
|
|
94
|
+
shapes=[],
|
|
95
|
+
charts=[],
|
|
96
|
+
table_candidates=tables,
|
|
97
|
+
)
|
|
98
|
+
logger.warning(
|
|
99
|
+
"%s Falling back to cells+tables only; shapes and charts will be empty.",
|
|
100
|
+
reason,
|
|
101
|
+
)
|
|
102
|
+
return WorkbookData(book_name=file_path.name, sheets=sheets)
|
|
103
|
+
|
|
104
|
+
if mode == "light":
|
|
105
|
+
return _cells_and_tables_only("Light mode selected.")
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
wb, close_app = _open_workbook(file_path)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
return _cells_and_tables_only(
|
|
111
|
+
f"xlwings/Excel COM is unavailable. ({e!r})"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
try:
|
|
116
|
+
shape_data = get_shapes_with_position(wb, mode=mode)
|
|
117
|
+
merged = integrate_sheet_content(cell_data, shape_data, wb, mode=mode)
|
|
118
|
+
return WorkbookData(book_name=file_path.name, sheets=merged)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning("Shape extraction failed; falling back to cells+tables. (%r)", e)
|
|
121
|
+
return _cells_and_tables_only(
|
|
122
|
+
f"Shape extraction failed ({e!r})."
|
|
123
|
+
)
|
|
124
|
+
finally:
|
|
125
|
+
# Close only if we created the app to avoid shutting user sessions.
|
|
126
|
+
try:
|
|
127
|
+
if close_app:
|
|
128
|
+
app = wb.app
|
|
129
|
+
wb.close()
|
|
130
|
+
app.quit()
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
exstruct/core/shapes.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import xlwings as xw
|
|
7
|
+
from xlwings import Book
|
|
8
|
+
|
|
9
|
+
from ..models import Shape
|
|
10
|
+
from ..models.maps import MSO_AUTO_SHAPE_TYPE_MAP, MSO_SHAPE_TYPE_MAP
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compute_line_angle_deg(w: float, h: float) -> float:
|
|
14
|
+
"""Compute clockwise angle in Excel coordinates where 0 deg points East."""
|
|
15
|
+
return math.degrees(math.atan2(h, w)) % 360.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def angle_to_compass(angle: float) -> str:
|
|
19
|
+
"""Convert angle to 8-point compass direction (0deg=E, 45deg=NE, 90deg=N, etc)."""
|
|
20
|
+
dirs = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"]
|
|
21
|
+
idx = int(((angle + 22.5) % 360) // 45)
|
|
22
|
+
return dirs[idx]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def coord_to_cell_by_edges(
|
|
26
|
+
row_edges: List[float], col_edges: List[float], x: float, y: float
|
|
27
|
+
) -> Optional[str]:
|
|
28
|
+
"""Estimate cell address from coordinates and cumulative edges; return None if out of range."""
|
|
29
|
+
|
|
30
|
+
def find_index(edges, pos):
|
|
31
|
+
for i in range(1, len(edges)):
|
|
32
|
+
if edges[i - 1] <= pos < edges[i]:
|
|
33
|
+
return i
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
r = find_index(row_edges, y)
|
|
37
|
+
c = find_index(col_edges, x)
|
|
38
|
+
if r is None or c is None:
|
|
39
|
+
return None
|
|
40
|
+
return f"{xw.utils.col_name(c)}{r}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def has_arrow(style_val) -> bool:
|
|
44
|
+
"""Return True if Excel arrow style value indicates an arrowhead."""
|
|
45
|
+
try:
|
|
46
|
+
v = int(style_val)
|
|
47
|
+
return v != 0
|
|
48
|
+
except Exception:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def iter_shapes_recursive(shp):
|
|
53
|
+
"""Yield shapes recursively, including group children."""
|
|
54
|
+
yield shp
|
|
55
|
+
try:
|
|
56
|
+
if shp.api.Type == 6:
|
|
57
|
+
items = shp.api.GroupItems
|
|
58
|
+
for i in range(1, items.Count + 1):
|
|
59
|
+
inner = items.Item(i)
|
|
60
|
+
try:
|
|
61
|
+
name = inner.Name
|
|
62
|
+
xl_shape = shp.parent.shapes[name]
|
|
63
|
+
except Exception:
|
|
64
|
+
xl_shape = None
|
|
65
|
+
|
|
66
|
+
if xl_shape is not None:
|
|
67
|
+
for s in iter_shapes_recursive(xl_shape):
|
|
68
|
+
yield s
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _should_include_shape(
|
|
74
|
+
*,
|
|
75
|
+
text: str,
|
|
76
|
+
shape_type_num: Optional[int],
|
|
77
|
+
shape_type_str: Optional[str],
|
|
78
|
+
autoshape_type_str: Optional[str],
|
|
79
|
+
shape_name: Optional[str],
|
|
80
|
+
output_mode: str = "standard",
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
Decide whether to emit a shape given output mode.
|
|
84
|
+
- standard: emit if text exists OR the shape is an arrow/line/connector.
|
|
85
|
+
- light: suppress shapes entirely (handled upstream, but guard defensively).
|
|
86
|
+
- verbose: include all (except already-filtered chart/comment/picture/form controls).
|
|
87
|
+
"""
|
|
88
|
+
if output_mode == "light":
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
is_relationship = False
|
|
92
|
+
if shape_type_num in (3, 9): # line/connector
|
|
93
|
+
is_relationship = True
|
|
94
|
+
if autoshape_type_str and ("Arrow" in autoshape_type_str or "Connector" in autoshape_type_str):
|
|
95
|
+
is_relationship = True
|
|
96
|
+
if shape_type_str and (
|
|
97
|
+
"Connector" in shape_type_str
|
|
98
|
+
or shape_type_str in ("Line", "ConnectLine")
|
|
99
|
+
):
|
|
100
|
+
is_relationship = True
|
|
101
|
+
if shape_name and ("Connector" in shape_name or "Line" in shape_name):
|
|
102
|
+
is_relationship = True
|
|
103
|
+
|
|
104
|
+
if output_mode == "standard":
|
|
105
|
+
return bool(text) or is_relationship
|
|
106
|
+
# verbose
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_shapes_with_position(workbook: Book, mode: str = "standard") -> Dict[str, List[Shape]]:
|
|
111
|
+
"""Scan shapes in a workbook and return per-sheet Shape lists with position info."""
|
|
112
|
+
shape_data: Dict[str, List[Shape]] = {}
|
|
113
|
+
for sheet in workbook.sheets:
|
|
114
|
+
shapes: List[Shape] = []
|
|
115
|
+
for root in sheet.shapes:
|
|
116
|
+
for shp in iter_shapes_recursive(root):
|
|
117
|
+
try:
|
|
118
|
+
shape_name = getattr(shp, "name", None)
|
|
119
|
+
except Exception:
|
|
120
|
+
shape_name = None
|
|
121
|
+
try:
|
|
122
|
+
type_num = shp.api.Type
|
|
123
|
+
shape_type_str = MSO_SHAPE_TYPE_MAP.get(
|
|
124
|
+
type_num, f"Unknown({type_num})"
|
|
125
|
+
)
|
|
126
|
+
if shape_type_str in ["Chart", "Comment", "Picture", "FormControl"]:
|
|
127
|
+
continue
|
|
128
|
+
autoshape_type_str = None
|
|
129
|
+
try:
|
|
130
|
+
astype_num = shp.api.AutoShapeType
|
|
131
|
+
autoshape_type_str = MSO_AUTO_SHAPE_TYPE_MAP.get(
|
|
132
|
+
astype_num, f"Unknown({astype_num})"
|
|
133
|
+
)
|
|
134
|
+
except Exception:
|
|
135
|
+
autoshape_type_str = None
|
|
136
|
+
except Exception:
|
|
137
|
+
type_num = None
|
|
138
|
+
shape_type_str = None
|
|
139
|
+
autoshape_type_str = None
|
|
140
|
+
try:
|
|
141
|
+
text = shp.text.strip() if shp.text else ""
|
|
142
|
+
except Exception:
|
|
143
|
+
text = ""
|
|
144
|
+
|
|
145
|
+
if not _should_include_shape(
|
|
146
|
+
text=text,
|
|
147
|
+
shape_type_num=type_num,
|
|
148
|
+
shape_type_str=shape_type_str,
|
|
149
|
+
autoshape_type_str=autoshape_type_str,
|
|
150
|
+
shape_name=shape_name,
|
|
151
|
+
output_mode=mode,
|
|
152
|
+
):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if autoshape_type_str and autoshape_type_str == "NotPrimitive" and shape_name:
|
|
156
|
+
type_label = shape_name
|
|
157
|
+
else:
|
|
158
|
+
type_label = (
|
|
159
|
+
f"{shape_type_str}-{autoshape_type_str}"
|
|
160
|
+
if autoshape_type_str
|
|
161
|
+
else (shape_type_str or shape_name or "Unknown")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
shape_obj = Shape(
|
|
165
|
+
text=text,
|
|
166
|
+
l=int(shp.left),
|
|
167
|
+
t=int(shp.top),
|
|
168
|
+
w=int(shp.width) if mode == "verbose" or shape_type_str == "Group" else None,
|
|
169
|
+
h=int(shp.height) if mode == "verbose" or shape_type_str == "Group" else None,
|
|
170
|
+
type=type_label,
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
is_relationship_geom = False
|
|
174
|
+
if type_num in (3, 9):
|
|
175
|
+
is_relationship_geom = True
|
|
176
|
+
if autoshape_type_str and ("Arrow" in autoshape_type_str or "Connector" in autoshape_type_str):
|
|
177
|
+
is_relationship_geom = True
|
|
178
|
+
if shape_type_str and (
|
|
179
|
+
"Connector" in shape_type_str or shape_type_str in ("Line", "ConnectLine")
|
|
180
|
+
):
|
|
181
|
+
is_relationship_geom = True
|
|
182
|
+
if shape_name and ("Connector" in shape_name or "Line" in shape_name):
|
|
183
|
+
is_relationship_geom = True
|
|
184
|
+
|
|
185
|
+
if is_relationship_geom:
|
|
186
|
+
angle = compute_line_angle_deg(float(shp.width), float(shp.height))
|
|
187
|
+
shape_obj.direction = angle_to_compass(angle) # type: ignore
|
|
188
|
+
try:
|
|
189
|
+
rot = float(shp.api.Rotation)
|
|
190
|
+
if abs(rot) > 1e-6:
|
|
191
|
+
shape_obj.rotation = rot
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
try:
|
|
195
|
+
begin_style = int(shp.api.Line.BeginArrowheadStyle)
|
|
196
|
+
end_style = int(shp.api.Line.EndArrowheadStyle)
|
|
197
|
+
shape_obj.begin_arrow_style = begin_style
|
|
198
|
+
shape_obj.end_arrow_style = end_style
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
elif type_num == 1 and (autoshape_type_str and "Arrow" in autoshape_type_str):
|
|
202
|
+
try:
|
|
203
|
+
rot = float(shp.api.Rotation)
|
|
204
|
+
if abs(rot) > 1e-6:
|
|
205
|
+
shape_obj.rotation = rot
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
shapes.append(shape_obj)
|
|
211
|
+
shape_data[sheet.name] = shapes
|
|
212
|
+
return shape_data
|