exstruct 0.2.80__py3-none-any.whl → 0.3.2__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 +23 -12
- exstruct/cli/main.py +20 -0
- exstruct/core/backends/__init__.py +7 -0
- exstruct/core/backends/base.py +42 -0
- exstruct/core/backends/com_backend.py +230 -0
- exstruct/core/backends/openpyxl_backend.py +191 -0
- exstruct/core/cells.py +999 -483
- exstruct/core/charts.py +243 -241
- exstruct/core/integrate.py +42 -375
- exstruct/core/logging_utils.py +16 -0
- exstruct/core/modeling.py +87 -0
- exstruct/core/pipeline.py +749 -0
- exstruct/core/ranges.py +48 -0
- exstruct/core/shapes.py +282 -36
- exstruct/core/workbook.py +114 -0
- exstruct/engine.py +51 -123
- exstruct/errors.py +12 -1
- exstruct/io/__init__.py +130 -138
- exstruct/io/serialize.py +112 -0
- exstruct/models/__init__.py +58 -8
- exstruct/render/__init__.py +3 -7
- {exstruct-0.2.80.dist-info → exstruct-0.3.2.dist-info}/METADATA +133 -18
- exstruct-0.3.2.dist-info/RECORD +30 -0
- exstruct-0.2.80.dist-info/RECORD +0 -20
- {exstruct-0.2.80.dist-info → exstruct-0.3.2.dist-info}/WHEEL +0 -0
- {exstruct-0.2.80.dist-info → exstruct-0.3.2.dist-info}/entry_points.txt +0 -0
exstruct/core/charts.py
CHANGED
|
@@ -1,241 +1,243 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from typing import Literal
|
|
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) -> str | None: # noqa: C901
|
|
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]: # noqa: C901
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Literal
|
|
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) -> str | None: # noqa: C901
|
|
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]: # noqa: C901
|
|
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
|
+
buf.append('"')
|
|
87
|
+
i += 1
|
|
88
|
+
continue
|
|
89
|
+
else:
|
|
90
|
+
buf.append(ch)
|
|
91
|
+
i += 1
|
|
92
|
+
continue
|
|
93
|
+
else:
|
|
94
|
+
if ch == '"':
|
|
95
|
+
in_str = True
|
|
96
|
+
buf.append('"')
|
|
97
|
+
i += 1
|
|
98
|
+
continue
|
|
99
|
+
elif ch == "(":
|
|
100
|
+
depth_paren += 1
|
|
101
|
+
buf.append(ch)
|
|
102
|
+
i += 1
|
|
103
|
+
continue
|
|
104
|
+
elif ch == ")":
|
|
105
|
+
depth_paren = max(0, depth_paren - 1)
|
|
106
|
+
buf.append(ch)
|
|
107
|
+
i += 1
|
|
108
|
+
continue
|
|
109
|
+
elif ch == "{":
|
|
110
|
+
depth_brace += 1
|
|
111
|
+
buf.append(ch)
|
|
112
|
+
i += 1
|
|
113
|
+
continue
|
|
114
|
+
elif ch == "}":
|
|
115
|
+
depth_brace = max(0, depth_brace - 1)
|
|
116
|
+
buf.append(ch)
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
elif (ch in sep_chars) and depth_paren == 0 and depth_brace == 0:
|
|
120
|
+
args.append("".join(buf).strip())
|
|
121
|
+
buf = []
|
|
122
|
+
i += 1
|
|
123
|
+
continue
|
|
124
|
+
else:
|
|
125
|
+
buf.append(ch)
|
|
126
|
+
i += 1
|
|
127
|
+
continue
|
|
128
|
+
if buf or (args and args_text.endswith(sep_chars)):
|
|
129
|
+
args.append("".join(buf).strip())
|
|
130
|
+
return args
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _unquote_excel_string(s: str | None) -> str | None:
|
|
134
|
+
"""Decode Excel-style quoted string; return None if not quoted."""
|
|
135
|
+
if s is None:
|
|
136
|
+
return None
|
|
137
|
+
st = s.strip()
|
|
138
|
+
if len(st) >= 2 and st[0] == '"' and st[-1] == '"':
|
|
139
|
+
inner = st[1:-1]
|
|
140
|
+
return inner.replace('""', '"')
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_series_formula(formula: str) -> dict[str, str | None] | None:
|
|
145
|
+
"""Parse =SERIES into a dict of references; return None on failure."""
|
|
146
|
+
args_text = _extract_series_args_text(formula)
|
|
147
|
+
if args_text is None:
|
|
148
|
+
return None
|
|
149
|
+
parts = _split_top_level_args(args_text)
|
|
150
|
+
name_part = parts[0].strip() if len(parts) >= 1 and parts[0].strip() != "" else None
|
|
151
|
+
x_part = parts[1].strip() if len(parts) >= 2 and parts[1].strip() != "" else None
|
|
152
|
+
y_part = parts[2].strip() if len(parts) >= 3 and parts[2].strip() != "" else None
|
|
153
|
+
plot_order_part = (
|
|
154
|
+
parts[3].strip() if len(parts) >= 4 and parts[3].strip() != "" else None
|
|
155
|
+
)
|
|
156
|
+
bubble_part = (
|
|
157
|
+
parts[4].strip() if len(parts) >= 5 and parts[4].strip() != "" else None
|
|
158
|
+
)
|
|
159
|
+
name_literal = _unquote_excel_string(name_part)
|
|
160
|
+
name_range = None if name_literal is not None else name_part
|
|
161
|
+
return {
|
|
162
|
+
"name_range": name_range,
|
|
163
|
+
"x_range": x_part,
|
|
164
|
+
"y_range": y_part,
|
|
165
|
+
"plot_order": plot_order_part,
|
|
166
|
+
"bubble_size_range": bubble_part,
|
|
167
|
+
"name_literal": name_literal,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_charts(
|
|
172
|
+
sheet: xw.Sheet, mode: Literal["light", "standard", "verbose"] = "standard"
|
|
173
|
+
) -> list[Chart]:
|
|
174
|
+
"""Parse charts in a sheet into Chart models; failed charts carry an error field."""
|
|
175
|
+
charts: list[Chart] = []
|
|
176
|
+
for ch in sheet.charts:
|
|
177
|
+
series_list: list[ChartSeries] = []
|
|
178
|
+
y_axis_title: str = ""
|
|
179
|
+
y_axis_range: list[int] = []
|
|
180
|
+
chart_type_label: str = "unknown"
|
|
181
|
+
error: str | None = None
|
|
182
|
+
chart_width: int | None = None
|
|
183
|
+
chart_height: int | None = None
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
chart_com = sheet.api.ChartObjects(ch.name).Chart
|
|
187
|
+
chart_type_num = chart_com.ChartType
|
|
188
|
+
chart_type_label = XL_CHART_TYPE_MAP.get(
|
|
189
|
+
chart_type_num, f"unknown_{chart_type_num}"
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
chart_width = int(ch.width)
|
|
193
|
+
chart_height = int(ch.height)
|
|
194
|
+
except Exception:
|
|
195
|
+
chart_width = None
|
|
196
|
+
chart_height = None
|
|
197
|
+
|
|
198
|
+
for s in chart_com.SeriesCollection():
|
|
199
|
+
parsed = parse_series_formula(getattr(s, "Formula", ""))
|
|
200
|
+
name_range = parsed["name_range"] if parsed else None
|
|
201
|
+
x_range = parsed["x_range"] if parsed else None
|
|
202
|
+
y_range = parsed["y_range"] if parsed else None
|
|
203
|
+
|
|
204
|
+
series_list.append(
|
|
205
|
+
ChartSeries(
|
|
206
|
+
name=s.Name,
|
|
207
|
+
name_range=name_range,
|
|
208
|
+
x_range=x_range,
|
|
209
|
+
y_range=y_range,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
y_axis = chart_com.Axes(2, 1)
|
|
215
|
+
if y_axis.HasTitle:
|
|
216
|
+
y_axis_title = y_axis.AxisTitle.Text
|
|
217
|
+
y_axis_range = [y_axis.MinimumScale, y_axis.MaximumScale]
|
|
218
|
+
except Exception:
|
|
219
|
+
y_axis_title = ""
|
|
220
|
+
y_axis_range = []
|
|
221
|
+
|
|
222
|
+
title = chart_com.ChartTitle.Text if chart_com.HasTitle else None
|
|
223
|
+
except Exception:
|
|
224
|
+
logger.warning("Failed to parse chart; returning with error string.")
|
|
225
|
+
title = None
|
|
226
|
+
error = "Failed to build chart JSON structure"
|
|
227
|
+
|
|
228
|
+
charts.append(
|
|
229
|
+
Chart(
|
|
230
|
+
name=ch.name,
|
|
231
|
+
chart_type=chart_type_label,
|
|
232
|
+
title=title,
|
|
233
|
+
y_axis_title=y_axis_title,
|
|
234
|
+
y_axis_range=[float(v) for v in y_axis_range],
|
|
235
|
+
w=chart_width,
|
|
236
|
+
h=chart_height,
|
|
237
|
+
series=series_list,
|
|
238
|
+
l=int(ch.left),
|
|
239
|
+
t=int(ch.top),
|
|
240
|
+
error=error,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return charts
|