exstruct 0.2.80__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.
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ import math
5
+ from typing import SupportsInt, cast
6
+
7
+ import xlwings as xw
8
+ from xlwings import Book
9
+
10
+ from ..models import Shape
11
+ from ..models.maps import MSO_AUTO_SHAPE_TYPE_MAP, MSO_SHAPE_TYPE_MAP
12
+
13
+
14
+ def compute_line_angle_deg(w: float, h: float) -> float:
15
+ """Compute clockwise angle in Excel coordinates where 0 deg points East."""
16
+ return math.degrees(math.atan2(h, w)) % 360.0
17
+
18
+
19
+ def angle_to_compass(angle: float) -> str:
20
+ """Convert angle to 8-point compass direction (0deg=E, 45deg=NE, 90deg=N, etc)."""
21
+ dirs = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"]
22
+ idx = int(((angle + 22.5) % 360) // 45)
23
+ return dirs[idx]
24
+
25
+
26
+ def coord_to_cell_by_edges(
27
+ row_edges: list[float], col_edges: list[float], x: float, y: float
28
+ ) -> str | None:
29
+ """Estimate cell address from coordinates and cumulative edges; return None if out of range."""
30
+
31
+ def find_index(edges: list[float], pos: float) -> int | None:
32
+ for i in range(1, len(edges)):
33
+ if edges[i - 1] <= pos < edges[i]:
34
+ return i
35
+ return None
36
+
37
+ r = find_index(row_edges, y)
38
+ c = find_index(col_edges, x)
39
+ if r is None or c is None:
40
+ return None
41
+ return f"{xw.utils.col_name(c)}{r}"
42
+
43
+
44
+ def has_arrow(style_val: object) -> bool:
45
+ """Return True if Excel arrow style value indicates an arrowhead."""
46
+ try:
47
+ v = int(cast(SupportsInt, style_val))
48
+ return v != 0
49
+ except Exception:
50
+ return False
51
+
52
+
53
+ def iter_shapes_recursive(shp: xw.Shape) -> Iterator[xw.Shape]:
54
+ """Yield shapes recursively, including group children."""
55
+ yield shp
56
+ try:
57
+ if shp.api.Type == 6:
58
+ items = shp.api.GroupItems
59
+ for i in range(1, items.Count + 1):
60
+ inner = items.Item(i)
61
+ try:
62
+ name = inner.Name
63
+ xl_shape = shp.parent.shapes[name]
64
+ except Exception:
65
+ xl_shape = None
66
+
67
+ if xl_shape is not None:
68
+ yield from iter_shapes_recursive(xl_shape)
69
+ except Exception:
70
+ pass
71
+
72
+
73
+ def _should_include_shape(
74
+ *,
75
+ text: str,
76
+ shape_type_num: int | None,
77
+ shape_type_str: str | None,
78
+ autoshape_type_str: str | None,
79
+ shape_name: str | None,
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 (
95
+ "Arrow" in autoshape_type_str or "Connector" in autoshape_type_str
96
+ ):
97
+ is_relationship = True
98
+ if shape_type_str and (
99
+ "Connector" in shape_type_str or shape_type_str in ("Line", "ConnectLine")
100
+ ):
101
+ is_relationship = True
102
+ if shape_name and ("Connector" in shape_name or "Line" in shape_name):
103
+ is_relationship = True
104
+
105
+ if output_mode == "standard":
106
+ return bool(text) or is_relationship
107
+ # verbose
108
+ return True
109
+
110
+
111
+ def get_shapes_with_position( # noqa: C901
112
+ workbook: Book, mode: str = "standard"
113
+ ) -> dict[str, list[Shape]]:
114
+ """Scan shapes in a workbook and return per-sheet Shape lists with position info."""
115
+ shape_data: dict[str, list[Shape]] = {}
116
+ for sheet in workbook.sheets:
117
+ shapes: list[Shape] = []
118
+ excel_names: list[tuple[str, int]] = []
119
+ node_index = 0
120
+ pending_connections: list[tuple[Shape, str | None, str | None]] = []
121
+ for root in sheet.shapes:
122
+ for shp in iter_shapes_recursive(root):
123
+ try:
124
+ shape_name = getattr(shp, "name", None)
125
+ except Exception:
126
+ shape_name = None
127
+ try:
128
+ type_num = shp.api.Type
129
+ shape_type_str = MSO_SHAPE_TYPE_MAP.get(
130
+ type_num, f"Unknown({type_num})"
131
+ )
132
+ if shape_type_str in ["Chart", "Comment", "Picture", "FormControl"]:
133
+ continue
134
+ autoshape_type_str = None
135
+ try:
136
+ astype_num = shp.api.AutoShapeType
137
+ autoshape_type_str = MSO_AUTO_SHAPE_TYPE_MAP.get(
138
+ astype_num, f"Unknown({astype_num})"
139
+ )
140
+ except Exception:
141
+ autoshape_type_str = None
142
+ except Exception:
143
+ type_num = None
144
+ shape_type_str = None
145
+ autoshape_type_str = None
146
+ try:
147
+ text = shp.text.strip() if shp.text else ""
148
+ except Exception:
149
+ text = ""
150
+
151
+ if not _should_include_shape(
152
+ text=text,
153
+ shape_type_num=type_num,
154
+ shape_type_str=shape_type_str,
155
+ autoshape_type_str=autoshape_type_str,
156
+ shape_name=shape_name,
157
+ output_mode=mode,
158
+ ):
159
+ continue
160
+
161
+ if (
162
+ autoshape_type_str
163
+ and autoshape_type_str == "NotPrimitive"
164
+ and shape_name
165
+ ):
166
+ type_label = shape_name
167
+ else:
168
+ type_label = (
169
+ f"{shape_type_str}-{autoshape_type_str}"
170
+ if autoshape_type_str
171
+ else (shape_type_str or shape_name or "Unknown")
172
+ )
173
+
174
+ is_relationship_geom = False
175
+ if type_num in (3, 9):
176
+ is_relationship_geom = True
177
+ if autoshape_type_str and (
178
+ "Arrow" in autoshape_type_str or "Connector" in autoshape_type_str
179
+ ):
180
+ is_relationship_geom = True
181
+ if shape_type_str and (
182
+ "Connector" in shape_type_str or shape_type_str in ("Line", "ConnectLine")
183
+ ):
184
+ is_relationship_geom = True
185
+ if shape_name and ("Connector" in shape_name or "Line" in shape_name):
186
+ is_relationship_geom = True
187
+
188
+ shape_id = None
189
+ if not is_relationship_geom:
190
+ node_index += 1
191
+ shape_id = node_index
192
+
193
+ excel_name = shape_name if isinstance(shape_name, str) else None
194
+
195
+ shape_obj = Shape(
196
+ id=shape_id,
197
+ text=text,
198
+ l=int(shp.left),
199
+ t=int(shp.top),
200
+ w=int(shp.width)
201
+ if mode == "verbose" or shape_type_str == "Group"
202
+ else None,
203
+ h=int(shp.height)
204
+ if mode == "verbose" or shape_type_str == "Group"
205
+ else None,
206
+ type=type_label,
207
+ )
208
+ if excel_name:
209
+ if shape_id is not None:
210
+ excel_names.append((excel_name, shape_id))
211
+ try:
212
+ begin_name: str | None = None
213
+ end_name: str | None = None
214
+ if is_relationship_geom:
215
+ angle = compute_line_angle_deg(
216
+ float(shp.width), float(shp.height)
217
+ )
218
+ shape_obj.direction = angle_to_compass(angle) # type: ignore
219
+ try:
220
+ rot = float(shp.api.Rotation)
221
+ if abs(rot) > 1e-6:
222
+ shape_obj.rotation = rot
223
+ except Exception:
224
+ pass
225
+ try:
226
+ begin_style = int(shp.api.Line.BeginArrowheadStyle)
227
+ end_style = int(shp.api.Line.EndArrowheadStyle)
228
+ shape_obj.begin_arrow_style = begin_style
229
+ shape_obj.end_arrow_style = end_style
230
+ except Exception:
231
+ pass
232
+ # Connector begin/end connected shapes (if this shape is a connector).
233
+ try:
234
+ connector = shp.api.ConnectorFormat
235
+ try:
236
+ begin_shape = connector.BeginConnectedShape
237
+ if begin_shape is not None:
238
+ name = getattr(begin_shape, "Name", None)
239
+ if isinstance(name, str):
240
+ begin_name = name
241
+ except Exception:
242
+ pass
243
+ try:
244
+ end_shape = connector.EndConnectedShape
245
+ if end_shape is not None:
246
+ name = getattr(end_shape, "Name", None)
247
+ if isinstance(name, str):
248
+ end_name = name
249
+ except Exception:
250
+ pass
251
+ except Exception:
252
+ # Not a connector or ConnectorFormat is unavailable.
253
+ pass
254
+ elif type_num == 1 and (
255
+ autoshape_type_str and "Arrow" in autoshape_type_str
256
+ ):
257
+ try:
258
+ rot = float(shp.api.Rotation)
259
+ if abs(rot) > 1e-6:
260
+ shape_obj.rotation = rot
261
+ except Exception:
262
+ pass
263
+ except Exception:
264
+ pass
265
+ pending_connections.append((shape_obj, begin_name, end_name))
266
+ shapes.append(shape_obj)
267
+ if pending_connections:
268
+ name_to_id = {name: sid for name, sid in excel_names}
269
+ for shape_obj, begin_name, end_name in pending_connections:
270
+ if begin_name:
271
+ shape_obj.begin_id = name_to_id.get(begin_name)
272
+ if end_name:
273
+ shape_obj.end_id = name_to_id.get(end_name)
274
+ shape_data[sheet.name] = shapes
275
+ return shape_data