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.
- exstruct/__init__.py +387 -0
- exstruct/cli/availability.py +49 -0
- exstruct/cli/main.py +134 -0
- exstruct/core/__init__.py +0 -0
- exstruct/core/cells.py +1039 -0
- exstruct/core/charts.py +241 -0
- exstruct/core/integrate.py +388 -0
- exstruct/core/shapes.py +275 -0
- exstruct/engine.py +643 -0
- exstruct/errors.py +35 -0
- exstruct/io/__init__.py +555 -0
- exstruct/models/__init__.py +335 -0
- exstruct/models/maps.py +335 -0
- exstruct/models/types.py +8 -0
- exstruct/py.typed +0 -0
- exstruct/render/__init__.py +118 -0
- exstruct-0.2.80.dist-info/METADATA +435 -0
- exstruct-0.2.80.dist-info/RECORD +20 -0
- exstruct-0.2.80.dist-info/WHEEL +4 -0
- exstruct-0.2.80.dist-info/entry_points.txt +3 -0
exstruct/core/shapes.py
ADDED
|
@@ -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
|