licos-dev-sdk 0.4.0__tar.gz → 0.4.1__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.
Files changed (40) hide show
  1. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/PKG-INFO +1 -1
  2. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/pyproject.toml +1 -1
  3. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document_table_export.py +109 -34
  4. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_document_table_export.py +68 -0
  5. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/.gitignore +0 -0
  6. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/__init__.py +0 -0
  7. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/_utils.py +0 -0
  8. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/archive.py +0 -0
  9. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/chart.py +0 -0
  10. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/data.py +0 -0
  11. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/diagram.py +0 -0
  12. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document.py +0 -0
  13. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document_common.py +0 -0
  14. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document_docx.py +0 -0
  15. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document_docx_edit.py +0 -0
  16. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document_docx_layout.py +0 -0
  17. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document_docx_table.py +0 -0
  18. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/document_read.py +0 -0
  19. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/image.py +0 -0
  20. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/model.py +0 -0
  21. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/observability.py +0 -0
  22. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  23. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
  24. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/presentation.py +0 -0
  25. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/presentation_canvas.py +0 -0
  26. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/presentation_inspect.py +0 -0
  27. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/spreadsheet.py +0 -0
  28. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
  29. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
  30. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  31. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/src/licos_dev_sdk/web.py +0 -0
  32. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_document_read.py +0 -0
  33. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_document_spreadsheet.py +0 -0
  34. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_docx_table_edit.py +0 -0
  35. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_model.py +0 -0
  36. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_observability.py +0 -0
  37. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_output_paths.py +0 -0
  38. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_ppt_master_runtime.py +0 -0
  39. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_presentation.py +0 -0
  40. {licos_dev_sdk-0.4.0 → licos_dev_sdk-0.4.1}/tests/test_spreadsheet_html_edit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: LICOS Dev SDK - file generation and model capability clients
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: beautifulsoup4>=4.12
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-sdk"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -60,34 +60,55 @@ def extract_docx_table_columns(
60
60
  previous_table_number: int | None = None
61
61
 
62
62
  for table_number, table in enumerate(tables, start=1):
63
- mapped_columns = tuple(
64
- _scale_column(start, span, header.column_count, table.column_count)
65
- for start, span in header.columns
63
+ local_header = _find_table_header_layout(
64
+ table,
65
+ normalized,
66
+ table_number=table_number,
66
67
  )
67
- sequence_column = (
68
- _scale_column(
68
+ layout = local_header or header
69
+ mapped_columns = (
70
+ tuple(start for start, _span in layout.columns)
71
+ if local_header is not None
72
+ else tuple(
73
+ _scale_column(start, span, header.column_count, table.column_count)
74
+ for start, span in header.columns
75
+ )
76
+ )
77
+ if local_header is not None:
78
+ sequence_column = local_header.sequence_column
79
+ elif header.sequence_column is not None:
80
+ sequence_column = _scale_column(
69
81
  header.sequence_column,
70
82
  1,
71
83
  header.column_count,
72
84
  table.column_count,
73
85
  )
74
- if header.sequence_column is not None
75
- else None
76
- )
86
+ else:
87
+ sequence_column = None
77
88
 
78
89
  for row in table.rows:
79
- if table_number == header.table_number and row.number <= header.row_number:
90
+ if local_header is not None and row.number <= local_header.row_number:
80
91
  continue
81
- if _is_header_row(row, normalized):
92
+ if _is_header_row(table, row, normalized):
82
93
  continue
83
94
 
84
- values = tuple(_text_at(row, column) for column in mapped_columns)
95
+ values = tuple(_text_at(table, row, column) for column in mapped_columns)
85
96
  if not any(values):
86
97
  continue
87
98
 
88
- sequence = _text_at(row, sequence_column) if sequence_column is not None else ""
99
+ sequence = (
100
+ _text_at(table, row, sequence_column)
101
+ if sequence_column is not None
102
+ else ""
103
+ )
89
104
  populated = sum(bool(value) for value in values)
90
- if populated == 1 and not _is_numeric_sequence(sequence):
105
+ if _is_structural_heading(table, row, mapped_columns):
106
+ continue
107
+ if (
108
+ populated == 1
109
+ and sequence_column is not None
110
+ and not _is_numeric_sequence(sequence)
111
+ ):
91
112
  if rows and previous_table_number == table_number and values[0]:
92
113
  rows[-1][selected[0]] = _join_continuation(rows[-1][selected[0]], values[0])
93
114
  continue
@@ -105,29 +126,56 @@ def _find_header_layout(
105
126
  normalized_columns: tuple[str, ...],
106
127
  ) -> _HeaderLayout:
107
128
  for table_number, table in enumerate(tables, start=1):
108
- for row in table.rows:
109
- cells = {_normalize_header(cell.text): cell for cell in row.cells if cell.text.strip()}
110
- matched = [cells.get(column) for column in normalized_columns]
111
- if all(cell is not None for cell in matched):
112
- sequence_column = _sequence_column(row)
113
- return _HeaderLayout(
114
- table_number=table_number,
115
- row_number=row.number,
116
- column_count=table.column_count,
117
- columns=tuple((cell.column, cell.col_span) for cell in matched if cell is not None),
118
- sequence_column=sequence_column,
119
- )
129
+ layout = _find_table_header_layout(
130
+ table,
131
+ normalized_columns,
132
+ table_number=table_number,
133
+ )
134
+ if layout is not None:
135
+ return layout
120
136
  display = ", ".join(normalized_columns)
121
137
  raise ValueError(f"no DOCX table header row contains all requested columns: {display}")
122
138
 
123
139
 
124
- def _is_header_row(row: TableRow, normalized_columns: tuple[str, ...]) -> bool:
125
- available = {_normalize_header(cell.text) for cell in row.cells if cell.text.strip()}
140
+ def _find_table_header_layout(
141
+ table: TableModel,
142
+ normalized_columns: tuple[str, ...],
143
+ *,
144
+ table_number: int,
145
+ ) -> _HeaderLayout | None:
146
+ for row in table.rows:
147
+ cells = {
148
+ _normalize_header(cell.text): cell
149
+ for cell in _visible_cells(table, row.number)
150
+ if cell.text.strip()
151
+ }
152
+ matched = [cells.get(column) for column in normalized_columns]
153
+ if all(cell is not None for cell in matched):
154
+ return _HeaderLayout(
155
+ table_number=table_number,
156
+ row_number=row.number,
157
+ column_count=table.column_count,
158
+ columns=tuple((cell.column, cell.col_span) for cell in matched if cell is not None),
159
+ sequence_column=_sequence_column(table, row.number),
160
+ )
161
+ return None
162
+
163
+
164
+ def _is_header_row(
165
+ table: TableModel,
166
+ row: TableRow,
167
+ normalized_columns: tuple[str, ...],
168
+ ) -> bool:
169
+ available = {
170
+ _normalize_header(cell.text)
171
+ for cell in _visible_cells(table, row.number)
172
+ if cell.text.strip()
173
+ }
126
174
  return all(column in available for column in normalized_columns)
127
175
 
128
176
 
129
- def _sequence_column(row: TableRow) -> int | None:
130
- for cell in row.cells:
177
+ def _sequence_column(table: TableModel, row_number: int) -> int | None:
178
+ for cell in _visible_cells(table, row_number):
131
179
  if _normalize_header(cell.text) in {"序号", "编号"}:
132
180
  return cell.column
133
181
  return None
@@ -148,13 +196,40 @@ def _scale_column(
148
196
  return min(max(mapped, 1), target_count)
149
197
 
150
198
 
151
- def _text_at(row: TableRow, column: int | None) -> str:
199
+ def _text_at(table: TableModel, row: TableRow, column: int | None) -> str:
152
200
  if column is None:
153
201
  return ""
154
- for cell in row.cells:
155
- if cell.column <= column < cell.column + cell.col_span:
156
- return cell.text.strip()
157
- return ""
202
+ cell = table.cell_at(row.number, column)
203
+ return cell.text.strip() if cell is not None else ""
204
+
205
+
206
+ def _visible_cells(table: TableModel, row_number: int) -> list[TableCell]:
207
+ cells: list[TableCell] = []
208
+ seen: set[int] = set()
209
+ for column in range(1, table.column_count + 1):
210
+ cell = table.cell_at(row_number, column)
211
+ if cell is not None and id(cell) not in seen:
212
+ cells.append(cell)
213
+ seen.add(id(cell))
214
+ return cells
215
+
216
+
217
+ def _is_structural_heading(
218
+ table: TableModel,
219
+ row: TableRow,
220
+ mapped_columns: tuple[int, ...],
221
+ ) -> bool:
222
+ nonempty = [cell for cell in _visible_cells(table, row.number) if cell.text.strip()]
223
+ if len(nonempty) != 1:
224
+ return False
225
+ cell = nonempty[0]
226
+ selected_coverage = sum(
227
+ cell.column <= column < cell.column + cell.col_span
228
+ for column in mapped_columns
229
+ )
230
+ return cell.col_span > 1 and (
231
+ selected_coverage > 1 or cell.col_span * 2 >= table.column_count
232
+ )
158
233
 
159
234
 
160
235
  def _normalize_header(value: str) -> str:
@@ -84,6 +84,74 @@ def test_create_xlsx_from_docx_writes_native_workbook(tmp_path: Path):
84
84
  workbook.close()
85
85
 
86
86
 
87
+ def test_extract_docx_columns_without_sequence_preserves_sparse_rows(tmp_path: Path):
88
+ source = tmp_path / "without-sequence.docx"
89
+ document = Document()
90
+ table = document.add_table(rows=5, cols=3)
91
+ for column, value in enumerate(["名称", "单位", "数量"]):
92
+ table.cell(0, column).text = value
93
+ for row, values in enumerate(
94
+ [
95
+ ["泵", "台", "2"],
96
+ ["备用泵", "", ""],
97
+ ["阀门", "个", ""],
98
+ ["液位计", "", "1"],
99
+ ],
100
+ start=1,
101
+ ):
102
+ for column, value in enumerate(values):
103
+ table.cell(row, column).text = value
104
+ document.save(source)
105
+
106
+ rows = extract_docx_table_columns(source, ["名称", "单位", "数量"])
107
+
108
+ assert rows == [
109
+ {"名称": "泵", "单位": "台", "数量": "2"},
110
+ {"名称": "备用泵", "单位": "", "数量": ""},
111
+ {"名称": "阀门", "单位": "个", "数量": ""},
112
+ {"名称": "液位计", "单位": "", "数量": "1"},
113
+ ]
114
+
115
+
116
+ def test_extract_docx_columns_without_sequence_skips_merged_heading(tmp_path: Path):
117
+ source = tmp_path / "merged-heading.docx"
118
+ document = Document()
119
+ table = document.add_table(rows=3, cols=3)
120
+ for column, value in enumerate(["名称", "单位", "数量"]):
121
+ table.cell(0, column).text = value
122
+ table.cell(1, 0).merge(table.cell(1, 2)).text = "泵类设备"
123
+ for column, value in enumerate(["循环泵", "台", "2"]):
124
+ table.cell(2, column).text = value
125
+ document.save(source)
126
+
127
+ rows = extract_docx_table_columns(source, ["名称", "单位", "数量"])
128
+
129
+ assert rows == [{"名称": "循环泵", "单位": "台", "数量": "2"}]
130
+
131
+
132
+ def test_extract_docx_columns_handles_multilevel_header_and_vertical_data_merge(tmp_path: Path):
133
+ source = tmp_path / "multilevel-header.docx"
134
+ document = Document()
135
+ table = document.add_table(rows=4, cols=3)
136
+ table.cell(0, 0).merge(table.cell(1, 0)).text = "名称"
137
+ table.cell(0, 1).merge(table.cell(0, 2)).text = "属性"
138
+ table.cell(1, 1).text = "单位"
139
+ table.cell(1, 2).text = "数量"
140
+ table.cell(2, 0).text = "主泵"
141
+ table.cell(2, 1).merge(table.cell(3, 1)).text = "台"
142
+ table.cell(2, 2).text = "2"
143
+ table.cell(3, 0).text = "备用泵"
144
+ table.cell(3, 2).text = "1"
145
+ document.save(source)
146
+
147
+ rows = extract_docx_table_columns(source, ["名称", "单位", "数量"])
148
+
149
+ assert rows == [
150
+ {"名称": "主泵", "单位": "台", "数量": "2"},
151
+ {"名称": "备用泵", "单位": "台", "数量": "1"},
152
+ ]
153
+
154
+
87
155
  def test_extract_docx_columns_requires_a_complete_header(tmp_path: Path):
88
156
  source = tmp_path / "source.docx"
89
157
  document = Document()
File without changes