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/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
- 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: str | None) -> str | None:
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) -> dict[str, str | None] | None:
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(
170
- sheet: xw.Sheet, mode: Literal["light", "standard", "verbose"] = "standard"
171
- ) -> list[Chart]:
172
- """Parse charts in a sheet into Chart models; failed charts carry an error field."""
173
- charts: list[Chart] = []
174
- for ch in sheet.charts:
175
- series_list: list[ChartSeries] = []
176
- y_axis_title: str = ""
177
- y_axis_range: list[int] = []
178
- chart_type_label: str = "unknown"
179
- error: str | None = None
180
-
181
- try:
182
- chart_com = sheet.api.ChartObjects(ch.name).Chart
183
- chart_type_num = chart_com.ChartType
184
- chart_type_label = XL_CHART_TYPE_MAP.get(
185
- chart_type_num, f"unknown_{chart_type_num}"
186
- )
187
- chart_width: int | None = None
188
- chart_height: int | None = None
189
- try:
190
- chart_width = int(ch.width)
191
- chart_height = int(ch.height)
192
- except Exception:
193
- chart_width = None
194
- chart_height = None
195
-
196
- for s in chart_com.SeriesCollection():
197
- parsed = parse_series_formula(getattr(s, "Formula", ""))
198
- name_range = parsed["name_range"] if parsed else None
199
- x_range = parsed["x_range"] if parsed else None
200
- y_range = parsed["y_range"] if parsed else None
201
-
202
- series_list.append(
203
- ChartSeries(
204
- name=s.Name,
205
- name_range=name_range,
206
- x_range=x_range,
207
- y_range=y_range,
208
- )
209
- )
210
-
211
- try:
212
- y_axis = chart_com.Axes(2, 1)
213
- if y_axis.HasTitle:
214
- y_axis_title = y_axis.AxisTitle.Text
215
- y_axis_range = [y_axis.MinimumScale, y_axis.MaximumScale]
216
- except Exception:
217
- y_axis_title = ""
218
- y_axis_range = []
219
-
220
- title = chart_com.ChartTitle.Text if chart_com.HasTitle else None
221
- except Exception:
222
- logger.warning("Failed to parse chart; returning with error string.")
223
- title = None
224
- error = "Failed to build chart JSON structure"
225
-
226
- charts.append(
227
- Chart(
228
- name=ch.name,
229
- chart_type=chart_type_label,
230
- title=title,
231
- y_axis_title=y_axis_title,
232
- y_axis_range=[float(v) for v in y_axis_range],
233
- w=chart_width,
234
- h=chart_height,
235
- series=series_list,
236
- l=int(ch.left),
237
- t=int(ch.top),
238
- error=error,
239
- )
240
- )
241
- return charts
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