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.
@@ -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
@@ -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