exstruct 0.2.70__tar.gz → 0.2.80__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: exstruct
3
- Version: 0.2.70
3
+ Version: 0.2.80
4
4
  Summary: Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines
5
5
  Keywords: excel,structure,data,exstruct
6
6
  Author: harumiWeb
@@ -67,7 +67,7 @@ ExStruct reads Excel workbooks and outputs structured data (cells, table candida
67
67
 
68
68
  - **Excel → Structured JSON**: cells, shapes, charts, table candidates, print areas/views, and auto page-break areas per sheet.
69
69
  - **Output modes**: `light` (cells + table candidates + print areas; no COM, shapes/charts empty), `standard` (texted shapes + arrows, charts, print areas), `verbose` (all shapes with width/height, charts with size, print areas). Verbose also emits cell hyperlinks. Size output is flag-controlled.
70
- - **Auto page-break export (COM only)**: capture Excel-computed auto page breaks and write per-area JSON/YAML/TOON when requested.
70
+ - **Auto page-break export (COM only)**: capture Excel-computed auto page breaks and write per-area JSON/YAML/TOON when requested (CLI option appears only when COM is available).
71
71
  - **Formats**: JSON (compact by default, `--pretty` available), YAML, TOON (optional dependencies).
72
72
  - **Table detection tuning**: adjust heuristics at runtime via API.
73
73
  - **CLI rendering** (Excel required): optional PDF and per-sheet PNGs.
@@ -99,11 +99,12 @@ exstruct input.xlsx --format yaml # YAML (needs pyyaml)
99
99
  exstruct input.xlsx --format toon # TOON (needs python-toon)
100
100
  exstruct input.xlsx --sheets-dir sheets/ # split per sheet in chosen format
101
101
  exstruct input.xlsx --print-areas-dir areas/ # split per print area (if any)
102
+ exstruct input.xlsx --auto-page-breaks-dir auto_areas/ # COM only; option appears when available
102
103
  exstruct input.xlsx --mode light # cells + table candidates only
103
104
  exstruct input.xlsx --pdf --image # PDF and PNGs (Excel required)
104
105
  ```
105
106
 
106
- Auto page-break exports are API-only (Excel/COM): set `DestinationOptions.auto_page_breaks_dir` or call `export_auto_page_breaks(...)`.
107
+ Auto page-break exports are available via API and CLI when Excel/COM is available; the CLI exposes `--auto-page-breaks-dir` only in COM-capable environments.
107
108
 
108
109
  ## Quick Start (Python)
109
110
 
@@ -404,12 +405,24 @@ In short, **exstruct = “an engine that converts Excel into a format AI can und
404
405
  - Default JSON is compact to reduce tokens; use `--pretty` or `pretty=True` when readability matters.
405
406
  - Field `table_candidates` replaces `tables`; adjust downstream consumers accordingly.
406
407
 
408
+ ## Enterprise Use
409
+
410
+ ExStruct is used primarily as a **library**, not a service.
411
+
412
+ - No official support or SLA is provided
413
+ - Long-term stability is prioritized over rapid feature growth
414
+ - Forking and internal modification are expected in enterprise use
415
+
416
+ This project is suitable for teams that:
417
+ - need transparency over black-box tools
418
+ - are comfortable maintaining internal forks if necessary
419
+
407
420
  ## Print Areas and Auto Page Breaks (PrintArea / PrintAreaView)
408
421
 
409
422
  - `SheetData.print_areas` holds print areas (cell coordinates) in light/standard/verbose.
410
423
  - `SheetData.auto_print_areas` holds Excel COM-computed auto page-break areas when auto page-break extraction is enabled (COM only).
411
424
  - Use `export_print_areas_as(...)` or CLI `--print-areas-dir` to write one file per print area (nothing is written if none exist).
412
- - Use `DestinationOptions.auto_page_breaks_dir` (preferred) or `export_auto_page_breaks(...)` to write per-auto-page-break files; the API raises `ValueError` if no auto page breaks exist.
425
+ - Use CLI `--auto-page-breaks-dir` (COM only), `DestinationOptions.auto_page_breaks_dir` (preferred), or `export_auto_page_breaks(...)` to write per-auto-page-break files; the API raises `ValueError` if no auto page breaks exist.
413
426
  - `PrintAreaView` includes rows and table candidates inside the area, plus shapes/charts that overlap the area (size-less shapes are treated as points). `normalize=True` rebases row/col indices to the area origin.
414
427
 
415
428
  ## License
@@ -419,3 +432,4 @@ BSD-3-Clause. See `LICENSE` for details.
419
432
  ## Documentation
420
433
 
421
434
  - API Reference (GitHub Pages): https://harumiweb.github.io/exstruct/
435
+ - JSON Schemas: see `schemas/` (one file per model); regenerate via `python scripts/gen_json_schema.py`.
@@ -12,7 +12,7 @@ ExStruct reads Excel workbooks and outputs structured data (cells, table candida
12
12
 
13
13
  - **Excel → Structured JSON**: cells, shapes, charts, table candidates, print areas/views, and auto page-break areas per sheet.
14
14
  - **Output modes**: `light` (cells + table candidates + print areas; no COM, shapes/charts empty), `standard` (texted shapes + arrows, charts, print areas), `verbose` (all shapes with width/height, charts with size, print areas). Verbose also emits cell hyperlinks. Size output is flag-controlled.
15
- - **Auto page-break export (COM only)**: capture Excel-computed auto page breaks and write per-area JSON/YAML/TOON when requested.
15
+ - **Auto page-break export (COM only)**: capture Excel-computed auto page breaks and write per-area JSON/YAML/TOON when requested (CLI option appears only when COM is available).
16
16
  - **Formats**: JSON (compact by default, `--pretty` available), YAML, TOON (optional dependencies).
17
17
  - **Table detection tuning**: adjust heuristics at runtime via API.
18
18
  - **CLI rendering** (Excel required): optional PDF and per-sheet PNGs.
@@ -44,11 +44,12 @@ exstruct input.xlsx --format yaml # YAML (needs pyyaml)
44
44
  exstruct input.xlsx --format toon # TOON (needs python-toon)
45
45
  exstruct input.xlsx --sheets-dir sheets/ # split per sheet in chosen format
46
46
  exstruct input.xlsx --print-areas-dir areas/ # split per print area (if any)
47
+ exstruct input.xlsx --auto-page-breaks-dir auto_areas/ # COM only; option appears when available
47
48
  exstruct input.xlsx --mode light # cells + table candidates only
48
49
  exstruct input.xlsx --pdf --image # PDF and PNGs (Excel required)
49
50
  ```
50
51
 
51
- Auto page-break exports are API-only (Excel/COM): set `DestinationOptions.auto_page_breaks_dir` or call `export_auto_page_breaks(...)`.
52
+ Auto page-break exports are available via API and CLI when Excel/COM is available; the CLI exposes `--auto-page-breaks-dir` only in COM-capable environments.
52
53
 
53
54
  ## Quick Start (Python)
54
55
 
@@ -349,12 +350,24 @@ In short, **exstruct = “an engine that converts Excel into a format AI can und
349
350
  - Default JSON is compact to reduce tokens; use `--pretty` or `pretty=True` when readability matters.
350
351
  - Field `table_candidates` replaces `tables`; adjust downstream consumers accordingly.
351
352
 
353
+ ## Enterprise Use
354
+
355
+ ExStruct is used primarily as a **library**, not a service.
356
+
357
+ - No official support or SLA is provided
358
+ - Long-term stability is prioritized over rapid feature growth
359
+ - Forking and internal modification are expected in enterprise use
360
+
361
+ This project is suitable for teams that:
362
+ - need transparency over black-box tools
363
+ - are comfortable maintaining internal forks if necessary
364
+
352
365
  ## Print Areas and Auto Page Breaks (PrintArea / PrintAreaView)
353
366
 
354
367
  - `SheetData.print_areas` holds print areas (cell coordinates) in light/standard/verbose.
355
368
  - `SheetData.auto_print_areas` holds Excel COM-computed auto page-break areas when auto page-break extraction is enabled (COM only).
356
369
  - Use `export_print_areas_as(...)` or CLI `--print-areas-dir` to write one file per print area (nothing is written if none exist).
357
- - Use `DestinationOptions.auto_page_breaks_dir` (preferred) or `export_auto_page_breaks(...)` to write per-auto-page-break files; the API raises `ValueError` if no auto page breaks exist.
370
+ - Use CLI `--auto-page-breaks-dir` (COM only), `DestinationOptions.auto_page_breaks_dir` (preferred), or `export_auto_page_breaks(...)` to write per-auto-page-break files; the API raises `ValueError` if no auto page breaks exist.
358
371
  - `PrintAreaView` includes rows and table candidates inside the area, plus shapes/charts that overlap the area (size-less shapes are treated as points). `normalize=True` rebases row/col indices to the area origin.
359
372
 
360
373
  ## License
@@ -364,3 +377,4 @@ BSD-3-Clause. See `LICENSE` for details.
364
377
  ## Documentation
365
378
 
366
379
  - API Reference (GitHub Pages): https://harumiweb.github.io/exstruct/
380
+ - JSON Schemas: see `schemas/` (one file per model); regenerate via `python scripts/gen_json_schema.py`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "exstruct"
3
- version = "0.2.70"
3
+ version = "0.2.80"
4
4
  description = "Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+ from pydantic import BaseModel, Field
8
+ import xlwings as xw
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ComAvailability(BaseModel):
14
+ """Availability information for Excel COM-dependent features."""
15
+
16
+ available: bool = Field(
17
+ ..., description="True when Excel COM can be used from this environment."
18
+ )
19
+ reason: str | None = Field(
20
+ default=None, description="Reason COM features are unavailable."
21
+ )
22
+
23
+
24
+ def get_com_availability() -> ComAvailability:
25
+ """Detect whether Excel COM is available for CLI features.
26
+
27
+ Returns:
28
+ ComAvailability describing whether COM features can be used.
29
+ """
30
+ if os.getenv("SKIP_COM_TESTS"):
31
+ return ComAvailability(available=False, reason="SKIP_COM_TESTS is set.")
32
+
33
+ if sys.platform != "win32":
34
+ return ComAvailability(available=False, reason="Non-Windows platform.")
35
+
36
+ try:
37
+ app = xw.App(add_book=False, visible=False)
38
+ except Exception as exc:
39
+ return ComAvailability(
40
+ available=False,
41
+ reason=f"Excel COM is unavailable ({exc.__class__.__name__}).",
42
+ )
43
+
44
+ try:
45
+ app.quit()
46
+ except Exception:
47
+ logger.warning("Failed to quit Excel during COM availability check.")
48
+
49
+ return ComAvailability(available=True, reason=None)
@@ -4,9 +4,33 @@ import argparse
4
4
  from pathlib import Path
5
5
 
6
6
  from exstruct import process_excel
7
+ from exstruct.cli.availability import ComAvailability, get_com_availability
7
8
 
8
9
 
9
- def build_parser() -> argparse.ArgumentParser:
10
+ def _add_auto_page_breaks_argument(
11
+ parser: argparse.ArgumentParser, availability: ComAvailability
12
+ ) -> None:
13
+ """Add auto page-break export option when COM is available."""
14
+ if not availability.available:
15
+ return
16
+ parser.add_argument(
17
+ "--auto-page-breaks-dir",
18
+ type=Path,
19
+ help="Optional directory to write one file per auto page-break area (COM only).",
20
+ )
21
+
22
+
23
+ def build_parser(
24
+ availability: ComAvailability | None = None,
25
+ ) -> argparse.ArgumentParser:
26
+ """Build the CLI argument parser.
27
+
28
+ Args:
29
+ availability: Optional COM availability for tests or overrides.
30
+
31
+ Returns:
32
+ Configured argument parser.
33
+ """
10
34
  parser = argparse.ArgumentParser(
11
35
  description="Dev-only CLI stub for ExStruct extraction."
12
36
  )
@@ -62,10 +86,22 @@ def build_parser() -> argparse.ArgumentParser:
62
86
  type=Path,
63
87
  help="Optional directory to write one file per print area (format follows --format).",
64
88
  )
89
+ resolved_availability = (
90
+ availability if availability is not None else get_com_availability()
91
+ )
92
+ _add_auto_page_breaks_argument(parser, resolved_availability)
65
93
  return parser
66
94
 
67
95
 
68
96
  def main(argv: list[str] | None = None) -> int:
97
+ """Run the CLI entrypoint.
98
+
99
+ Args:
100
+ argv: Optional argument list for testing.
101
+
102
+ Returns:
103
+ Exit code (0 for success, 1 for failure).
104
+ """
69
105
  parser = build_parser()
70
106
  args = parser.parse_args(argv)
71
107
 
@@ -86,6 +122,7 @@ def main(argv: list[str] | None = None) -> int:
86
122
  pretty=args.pretty,
87
123
  sheets_dir=args.sheets_dir,
88
124
  print_areas_dir=args.print_areas_dir,
125
+ auto_page_breaks_dir=getattr(args, "auto_page_breaks_dir", None),
89
126
  )
90
127
  return 0
91
128
  except Exception as e:
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Iterator
4
- import math
5
- from typing import SupportsInt, cast
3
+ from collections.abc import Iterator
4
+ import math
5
+ from typing import SupportsInt, cast
6
6
 
7
7
  import xlwings as xw
8
8
  from xlwings import Book
@@ -23,54 +23,54 @@ def angle_to_compass(angle: float) -> str:
23
23
  return dirs[idx]
24
24
 
25
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
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
36
 
37
37
  r = find_index(row_edges, y)
38
38
  c = find_index(col_edges, x)
39
39
  if r is None or c is None:
40
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(
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
74
  *,
75
75
  text: str,
76
76
  shape_type_num: int | None,
@@ -108,13 +108,16 @@ def _should_include_shape(
108
108
  return True
109
109
 
110
110
 
111
- def get_shapes_with_position( # noqa: C901
112
- workbook: Book, mode: str = "standard"
113
- ) -> dict[str, list[Shape]]:
111
+ def get_shapes_with_position( # noqa: C901
112
+ workbook: Book, mode: str = "standard"
113
+ ) -> dict[str, list[Shape]]:
114
114
  """Scan shapes in a workbook and return per-sheet Shape lists with position info."""
115
115
  shape_data: dict[str, list[Shape]] = {}
116
116
  for sheet in workbook.sheets:
117
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]] = []
118
121
  for root in sheet.shapes:
119
122
  for shp in iter_shapes_recursive(root):
120
123
  try:
@@ -168,7 +171,29 @@ def get_shapes_with_position( # noqa: C901
168
171
  else (shape_type_str or shape_name or "Unknown")
169
172
  )
170
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
+
171
195
  shape_obj = Shape(
196
+ id=shape_id,
172
197
  text=text,
173
198
  l=int(shp.left),
174
199
  t=int(shp.top),
@@ -180,25 +205,12 @@ def get_shapes_with_position( # noqa: C901
180
205
  else None,
181
206
  type=type_label,
182
207
  )
208
+ if excel_name:
209
+ if shape_id is not None:
210
+ excel_names.append((excel_name, shape_id))
183
211
  try:
184
- is_relationship_geom = False
185
- if type_num in (3, 9):
186
- is_relationship_geom = True
187
- if autoshape_type_str and (
188
- "Arrow" in autoshape_type_str
189
- or "Connector" in autoshape_type_str
190
- ):
191
- is_relationship_geom = True
192
- if shape_type_str and (
193
- "Connector" in shape_type_str
194
- or shape_type_str in ("Line", "ConnectLine")
195
- ):
196
- is_relationship_geom = True
197
- if shape_name and (
198
- "Connector" in shape_name or "Line" in shape_name
199
- ):
200
- is_relationship_geom = True
201
-
212
+ begin_name: str | None = None
213
+ end_name: str | None = None
202
214
  if is_relationship_geom:
203
215
  angle = compute_line_angle_deg(
204
216
  float(shp.width), float(shp.height)
@@ -217,6 +229,28 @@ def get_shapes_with_position( # noqa: C901
217
229
  shape_obj.end_arrow_style = end_style
218
230
  except Exception:
219
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
220
254
  elif type_num == 1 and (
221
255
  autoshape_type_str and "Arrow" in autoshape_type_str
222
256
  ):
@@ -228,6 +262,14 @@ def get_shapes_with_position( # noqa: C901
228
262
  pass
229
263
  except Exception:
230
264
  pass
265
+ pending_connections.append((shape_obj, begin_name, end_name))
231
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)
232
274
  shape_data[sheet.name] = shapes
233
275
  return shape_data
@@ -11,6 +11,9 @@ from pydantic import BaseModel, Field
11
11
  class Shape(BaseModel):
12
12
  """Shape metadata (position, size, text, and styling)."""
13
13
 
14
+ id: int | None = Field(
15
+ default=None, description="Sequential shape id within the sheet (if applicable)."
16
+ )
14
17
  text: str = Field(description="Visible text content of the shape.")
15
18
  l: int = Field(description="Left offset (Excel units).") # noqa: E741
16
19
  t: int = Field(description="Top offset (Excel units).")
@@ -26,6 +29,18 @@ class Shape(BaseModel):
26
29
  end_arrow_style: int | None = Field(
27
30
  default=None, description="Arrow style enum for the end of a connector."
28
31
  )
32
+ begin_id: int | None = Field(
33
+ default=None,
34
+ description=(
35
+ "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape)."
36
+ ),
37
+ )
38
+ end_id: int | None = Field(
39
+ default=None,
40
+ description=(
41
+ "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape)."
42
+ ),
43
+ )
29
44
  direction: Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"] | None = Field(
30
45
  default=None, description="Connector direction (compass heading)."
31
46
  )
File without changes