licos-dev-sdk 0.3.9__tar.gz → 0.4.0__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.
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/PKG-INFO +3 -2
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/pyproject.toml +6 -2
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/__init__.py +6 -2
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/document.py +15 -0
- licos_dev_sdk-0.4.0/src/licos_dev_sdk/document_docx_edit.py +561 -0
- licos_dev_sdk-0.4.0/src/licos_dev_sdk/document_docx_table.py +465 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/document_read.py +102 -26
- licos_dev_sdk-0.4.0/src/licos_dev_sdk/document_table_export.py +174 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/ppt_master_runtime.py +1 -1
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/spreadsheet.py +53 -0
- licos_dev_sdk-0.4.0/src/licos_dev_sdk/spreadsheet_edit.py +281 -0
- licos_dev_sdk-0.4.0/src/licos_dev_sdk/spreadsheet_html.py +351 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/tests/test_document_read.py +9 -6
- licos_dev_sdk-0.4.0/tests/test_document_table_export.py +100 -0
- licos_dev_sdk-0.4.0/tests/test_docx_table_edit.py +138 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/tests/test_ppt_master_runtime.py +21 -0
- licos_dev_sdk-0.4.0/tests/test_spreadsheet_html_edit.py +106 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/.gitignore +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/document_common.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/document_docx.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/document_docx_layout.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/model.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/observability.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/pdf_inspect.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/presentation_canvas.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/presentation_inspect.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/tests/test_document_spreadsheet.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/tests/test_model.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/tests/test_observability.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/tests/test_output_paths.py +0 -0
- {licos_dev_sdk-0.3.9 → licos_dev_sdk-0.4.0}/tests/test_presentation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: licos-dev-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -8,7 +8,6 @@ Requires-Dist: docxtpl>=0.16
|
|
|
8
8
|
Requires-Dist: graphviz>=0.20
|
|
9
9
|
Requires-Dist: jinja2>=3.1
|
|
10
10
|
Requires-Dist: licos-platform-sdk>=0.3.2
|
|
11
|
-
Requires-Dist: licos-ppt-master-assets==0.0.1
|
|
12
11
|
Requires-Dist: matplotlib>=3.9
|
|
13
12
|
Requires-Dist: mistune>=3.0
|
|
14
13
|
Requires-Dist: openpyxl>=3.1
|
|
@@ -23,3 +22,5 @@ Requires-Dist: skia-pathops>=0.9.2
|
|
|
23
22
|
Requires-Dist: uharfbuzz>=0.50.0
|
|
24
23
|
Requires-Dist: weasyprint>=62.0
|
|
25
24
|
Requires-Dist: xlsxwriter>=3.2
|
|
25
|
+
Provides-Extra: ppt-master
|
|
26
|
+
Requires-Dist: licos-ppt-master-assets==0.0.1; extra == 'ppt-master'
|
|
@@ -4,11 +4,10 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "licos-dev-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "LICOS Dev SDK - file generation and model capability clients"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"licos-ppt-master-assets==0.0.1",
|
|
12
11
|
"licos-platform-sdk>=0.3.2",
|
|
13
12
|
"weasyprint>=62.0",
|
|
14
13
|
"python-docx>=1.1",
|
|
@@ -30,5 +29,10 @@ dependencies = [
|
|
|
30
29
|
"PyYAML>=6.0",
|
|
31
30
|
]
|
|
32
31
|
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
ppt-master = [
|
|
34
|
+
"licos-ppt-master-assets==0.0.1",
|
|
35
|
+
]
|
|
36
|
+
|
|
33
37
|
[tool.hatch.build.targets.wheel]
|
|
34
38
|
packages = ["src/licos_dev_sdk"]
|
|
@@ -28,13 +28,17 @@ def __getattr__(name: str):
|
|
|
28
28
|
"inspect_pdf": ("pdf_inspect", "inspect_pdf"),
|
|
29
29
|
"create_docx": ("document", "create_docx"),
|
|
30
30
|
"create_docx_from_template": ("document", "create_docx_from_template"),
|
|
31
|
+
"edit_docx": ("document", "edit_docx"),
|
|
31
32
|
"read_pdf": ("document_read", "read_pdf"),
|
|
32
33
|
"read_docx": ("document_read", "read_docx"),
|
|
33
34
|
"read_xlsx": ("document_read", "read_xlsx"),
|
|
34
35
|
"read_pptx": ("document_read", "read_pptx"),
|
|
35
36
|
# spreadsheet
|
|
36
37
|
"create_xlsx": ("spreadsheet", "create_xlsx"),
|
|
38
|
+
"create_xlsx_from_docx": ("spreadsheet", "create_xlsx_from_docx"),
|
|
39
|
+
"create_xlsx_from_html": ("spreadsheet", "create_xlsx_from_html"),
|
|
37
40
|
"create_xlsx_workbook": ("spreadsheet", "create_xlsx_workbook"),
|
|
41
|
+
"edit_xlsx": ("spreadsheet", "edit_xlsx"),
|
|
38
42
|
"create_csv": ("spreadsheet", "create_csv"),
|
|
39
43
|
"inspect_xlsx": ("spreadsheet_inspect", "inspect_xlsx"),
|
|
40
44
|
# chart
|
|
@@ -100,9 +104,9 @@ __all__ = [
|
|
|
100
104
|
"create_zip", "create_tar_gz",
|
|
101
105
|
"create_qrcode", "create_barcode", "create_watermark",
|
|
102
106
|
"create_html", "create_markdown", "markdown_to_html",
|
|
103
|
-
"create_pdf", "inspect_pdf", "create_docx", "create_docx_from_template",
|
|
107
|
+
"create_pdf", "inspect_pdf", "create_docx", "create_docx_from_template", "edit_docx",
|
|
104
108
|
"read_pdf", "read_docx", "read_xlsx", "read_pptx",
|
|
105
|
-
"create_xlsx", "create_xlsx_workbook", "create_csv", "inspect_xlsx",
|
|
109
|
+
"create_xlsx", "create_xlsx_from_docx", "create_xlsx_from_html", "create_xlsx_workbook", "edit_xlsx", "create_csv", "inspect_xlsx",
|
|
106
110
|
"create_chart",
|
|
107
111
|
"create_diagram",
|
|
108
112
|
"create_pptx", "create_pptx_deck", "inspect_pptx",
|
|
@@ -72,3 +72,18 @@ def create_docx_from_template(template_path: str, data: dict[str, Any], filename
|
|
|
72
72
|
doc.render(data)
|
|
73
73
|
doc.save(str(path))
|
|
74
74
|
return str(path.resolve())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def edit_docx(
|
|
78
|
+
source_path: str,
|
|
79
|
+
operations: list[dict[str, Any]],
|
|
80
|
+
filename: str,
|
|
81
|
+
*,
|
|
82
|
+
output_dir: str | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Apply explicit table edits to an existing DOCX file."""
|
|
85
|
+
|
|
86
|
+
from .document_docx_edit import edit_docx_tables
|
|
87
|
+
|
|
88
|
+
path = resolve_output_path(filename, ".docx", output_dir)
|
|
89
|
+
return edit_docx_tables(source_path, path, operations)
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""Targeted edits for native DOCX tables while preserving the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any
|
|
11
|
+
from zipfile import ZIP_DEFLATED, ZipFile
|
|
12
|
+
|
|
13
|
+
from docx.oxml.ns import qn
|
|
14
|
+
from lxml import etree
|
|
15
|
+
|
|
16
|
+
from .document_docx_table import TableCell, TableModel, parse_table
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_DOCUMENT_XML = "word/document.xml"
|
|
20
|
+
_HEX_COLOR = re.compile(r"^[0-9A-Fa-f]{6}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def edit_docx_tables(
|
|
24
|
+
source: str | Path,
|
|
25
|
+
output: str | Path,
|
|
26
|
+
operations: list[dict[str, Any]],
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Apply explicit table operations to a DOCX package."""
|
|
29
|
+
|
|
30
|
+
source_path = Path(source).expanduser().resolve()
|
|
31
|
+
output_path = Path(output).expanduser().resolve()
|
|
32
|
+
if not source_path.is_file():
|
|
33
|
+
raise FileNotFoundError(f"document not found: {source_path}")
|
|
34
|
+
if source_path.suffix.lower() != ".docx":
|
|
35
|
+
raise ValueError(f"expected a .docx file, got {source_path.suffix.lower() or '<none>'}")
|
|
36
|
+
if not isinstance(operations, list) or not operations:
|
|
37
|
+
raise ValueError("operations must be a non-empty array")
|
|
38
|
+
if not all(isinstance(operation, dict) for operation in operations):
|
|
39
|
+
raise TypeError("every DOCX edit operation must be an object")
|
|
40
|
+
|
|
41
|
+
with ZipFile(source_path, "r") as archive:
|
|
42
|
+
if _DOCUMENT_XML not in archive.namelist():
|
|
43
|
+
raise ValueError("DOCX package does not contain word/document.xml")
|
|
44
|
+
root = etree.fromstring(archive.read(_DOCUMENT_XML))
|
|
45
|
+
for operation_number, operation in enumerate(operations, start=1):
|
|
46
|
+
try:
|
|
47
|
+
_apply_operation(root, operation)
|
|
48
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
49
|
+
name = operation.get("op", "<missing>")
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"DOCX operation {operation_number} ({name}) failed: {exc}"
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
handle, temporary_name = tempfile.mkstemp(
|
|
56
|
+
prefix=f".{output_path.stem}-",
|
|
57
|
+
suffix=".docx",
|
|
58
|
+
dir=output_path.parent,
|
|
59
|
+
)
|
|
60
|
+
os.close(handle)
|
|
61
|
+
temporary_path = Path(temporary_name)
|
|
62
|
+
try:
|
|
63
|
+
with ZipFile(temporary_path, "w", ZIP_DEFLATED) as destination:
|
|
64
|
+
for info in archive.infolist():
|
|
65
|
+
payload = archive.read(info.filename)
|
|
66
|
+
if info.filename == _DOCUMENT_XML:
|
|
67
|
+
payload = etree.tostring(
|
|
68
|
+
root,
|
|
69
|
+
xml_declaration=True,
|
|
70
|
+
encoding="UTF-8",
|
|
71
|
+
standalone=True,
|
|
72
|
+
)
|
|
73
|
+
destination.writestr(info, payload)
|
|
74
|
+
os.replace(temporary_path, output_path)
|
|
75
|
+
finally:
|
|
76
|
+
temporary_path.unlink(missing_ok=True)
|
|
77
|
+
return str(output_path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _apply_operation(root: Any, operation: dict[str, Any]) -> None:
|
|
81
|
+
name = str(_required(operation, "op")).strip().lower()
|
|
82
|
+
table_element = _table(root, _positive(operation, "table"))
|
|
83
|
+
model = parse_table(table_element, include_nested=False)
|
|
84
|
+
|
|
85
|
+
if name == "set_cell":
|
|
86
|
+
cell = _cell(model, operation)
|
|
87
|
+
_set_cell_content(cell.element, operation)
|
|
88
|
+
elif name == "set_cell_style":
|
|
89
|
+
_apply_cell_style(_cell(model, operation).element, _style(operation))
|
|
90
|
+
elif name == "set_row_style":
|
|
91
|
+
row = _row(model, _positive(operation, "row"))
|
|
92
|
+
_apply_row_style(row.element, _style(operation))
|
|
93
|
+
elif name == "set_column_width":
|
|
94
|
+
_set_column_width(
|
|
95
|
+
table_element,
|
|
96
|
+
model,
|
|
97
|
+
_positive(operation, "column"),
|
|
98
|
+
_positive(operation, "width_twips"),
|
|
99
|
+
)
|
|
100
|
+
elif name == "merge_cells":
|
|
101
|
+
_merge_cells(table_element, model, operation)
|
|
102
|
+
elif name == "unmerge_cells":
|
|
103
|
+
_unmerge_cells(table_element, model, operation)
|
|
104
|
+
elif name == "insert_rows":
|
|
105
|
+
_insert_rows(table_element, model, operation)
|
|
106
|
+
elif name == "delete_rows":
|
|
107
|
+
_delete_rows(table_element, model, operation)
|
|
108
|
+
elif name == "insert_columns":
|
|
109
|
+
_insert_columns(table_element, model, operation)
|
|
110
|
+
elif name == "delete_columns":
|
|
111
|
+
_delete_columns(table_element, model, operation)
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError(f"unsupported operation: {name!r}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _table(root: Any, table_number: int) -> Any:
|
|
117
|
+
body = root.find(qn("w:body"))
|
|
118
|
+
tables = list(body.iterchildren(tag=qn("w:tbl"))) if body is not None else []
|
|
119
|
+
if table_number > len(tables):
|
|
120
|
+
raise ValueError(f"table {table_number} not found; document has {len(tables)} top-level tables")
|
|
121
|
+
return tables[table_number - 1]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cell(model: TableModel, operation: dict[str, Any]) -> TableCell:
|
|
125
|
+
row = _positive(operation, "row")
|
|
126
|
+
column = _positive(operation, "column")
|
|
127
|
+
cell = model.cell_at(row, column)
|
|
128
|
+
if cell is None:
|
|
129
|
+
raise ValueError(f"cell at row {row}, column {column} does not exist")
|
|
130
|
+
return cell
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _row(model: TableModel, number: int):
|
|
134
|
+
if number > len(model.rows):
|
|
135
|
+
raise ValueError(f"row {number} not found; table has {len(model.rows)} rows")
|
|
136
|
+
return model.rows[number - 1]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _set_cell_content(cell_element: Any, operation: dict[str, Any]) -> None:
|
|
140
|
+
raw_paragraphs = operation.get("paragraphs")
|
|
141
|
+
if raw_paragraphs is None:
|
|
142
|
+
text = operation.get("value", operation.get("text"))
|
|
143
|
+
if text is None:
|
|
144
|
+
raise ValueError("set_cell requires value, text, or paragraphs")
|
|
145
|
+
raw_paragraphs = str(text).splitlines() or [""]
|
|
146
|
+
if not isinstance(raw_paragraphs, list):
|
|
147
|
+
raise TypeError("paragraphs must be an array")
|
|
148
|
+
|
|
149
|
+
existing_paragraph = cell_element.find(qn("w:p"))
|
|
150
|
+
paragraph_properties = None
|
|
151
|
+
run_properties = None
|
|
152
|
+
if existing_paragraph is not None:
|
|
153
|
+
p_pr = existing_paragraph.find(qn("w:pPr"))
|
|
154
|
+
paragraph_properties = deepcopy(p_pr) if p_pr is not None else None
|
|
155
|
+
existing_run = existing_paragraph.find(qn("w:r"))
|
|
156
|
+
if existing_run is not None:
|
|
157
|
+
r_pr = existing_run.find(qn("w:rPr"))
|
|
158
|
+
run_properties = deepcopy(r_pr) if r_pr is not None else None
|
|
159
|
+
|
|
160
|
+
for child in list(cell_element):
|
|
161
|
+
if child.tag != qn("w:tcPr"):
|
|
162
|
+
cell_element.remove(child)
|
|
163
|
+
for raw in raw_paragraphs or [""]:
|
|
164
|
+
paragraph_spec = raw if isinstance(raw, dict) else {"text": str(raw)}
|
|
165
|
+
paragraph = etree.Element(qn("w:p"))
|
|
166
|
+
if paragraph_properties is not None:
|
|
167
|
+
paragraph.append(deepcopy(paragraph_properties))
|
|
168
|
+
run = etree.SubElement(paragraph, qn("w:r"))
|
|
169
|
+
if run_properties is not None:
|
|
170
|
+
run.append(deepcopy(run_properties))
|
|
171
|
+
_append_text(run, str(paragraph_spec.get("text", "")))
|
|
172
|
+
cell_element.append(paragraph)
|
|
173
|
+
if isinstance(operation.get("style"), dict):
|
|
174
|
+
_apply_cell_style(cell_element, operation["style"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _append_text(run: Any, text: str) -> None:
|
|
178
|
+
segments = text.split("\n")
|
|
179
|
+
for index, segment in enumerate(segments):
|
|
180
|
+
if index:
|
|
181
|
+
etree.SubElement(run, qn("w:br"))
|
|
182
|
+
text_element = etree.SubElement(run, qn("w:t"))
|
|
183
|
+
if segment[:1].isspace() or segment[-1:].isspace():
|
|
184
|
+
text_element.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
185
|
+
text_element.text = segment
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _apply_cell_style(cell_element: Any, style: dict[str, Any]) -> None:
|
|
189
|
+
properties = _ensure_first(cell_element, "tcPr")
|
|
190
|
+
if "fill" in style:
|
|
191
|
+
fill = _color(style["fill"])
|
|
192
|
+
shading = _ensure(properties, "shd")
|
|
193
|
+
shading.set(qn("w:fill"), fill)
|
|
194
|
+
shading.set(qn("w:val"), "clear")
|
|
195
|
+
if "vertical_alignment" in style:
|
|
196
|
+
value = str(style["vertical_alignment"]).lower()
|
|
197
|
+
if value not in {"top", "center", "bottom"}:
|
|
198
|
+
raise ValueError("vertical_alignment must be top, center, or bottom")
|
|
199
|
+
_ensure(properties, "vAlign").set(qn("w:val"), value)
|
|
200
|
+
if "text_direction" in style:
|
|
201
|
+
_ensure(properties, "textDirection").set(qn("w:val"), str(style["text_direction"]))
|
|
202
|
+
if "width_twips" in style:
|
|
203
|
+
width = _positive_value(style["width_twips"], "width_twips")
|
|
204
|
+
target = _ensure(properties, "tcW")
|
|
205
|
+
target.set(qn("w:w"), str(width))
|
|
206
|
+
target.set(qn("w:type"), "dxa")
|
|
207
|
+
if "margins" in style:
|
|
208
|
+
margins = style["margins"]
|
|
209
|
+
if not isinstance(margins, dict):
|
|
210
|
+
raise TypeError("margins must be an object")
|
|
211
|
+
container = _ensure(properties, "tcMar")
|
|
212
|
+
for side, raw in margins.items():
|
|
213
|
+
if side not in {"top", "left", "bottom", "right", "start", "end"}:
|
|
214
|
+
raise ValueError(f"unsupported cell margin side: {side}")
|
|
215
|
+
target = _ensure(container, side)
|
|
216
|
+
target.set(qn("w:w"), str(_non_negative(raw, f"margins.{side}")))
|
|
217
|
+
target.set(qn("w:type"), "dxa")
|
|
218
|
+
if "borders" in style:
|
|
219
|
+
borders = style["borders"]
|
|
220
|
+
if not isinstance(borders, dict):
|
|
221
|
+
raise TypeError("borders must be an object")
|
|
222
|
+
container = _ensure(properties, "tcBorders")
|
|
223
|
+
for side, spec in borders.items():
|
|
224
|
+
if side not in {"top", "left", "bottom", "right", "insideH", "insideV"}:
|
|
225
|
+
raise ValueError(f"unsupported border side: {side}")
|
|
226
|
+
if not isinstance(spec, dict):
|
|
227
|
+
raise TypeError(f"border {side} must be an object")
|
|
228
|
+
target = _ensure(container, side)
|
|
229
|
+
target.set(qn("w:val"), str(spec.get("style", "single")))
|
|
230
|
+
if "size" in spec:
|
|
231
|
+
target.set(qn("w:sz"), str(_positive_value(spec["size"], f"borders.{side}.size")))
|
|
232
|
+
if "color" in spec:
|
|
233
|
+
target.set(qn("w:color"), _color(spec["color"]))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _apply_row_style(row_element: Any, style: dict[str, Any]) -> None:
|
|
237
|
+
properties = _ensure_first(row_element, "trPr")
|
|
238
|
+
for key, tag in (("repeat_header", "tblHeader"), ("cant_split", "cantSplit")):
|
|
239
|
+
if key in style:
|
|
240
|
+
_toggle(properties, tag, bool(style[key]))
|
|
241
|
+
if "height_twips" in style:
|
|
242
|
+
height = _ensure(properties, "trHeight")
|
|
243
|
+
height.set(qn("w:val"), str(_positive_value(style["height_twips"], "height_twips")))
|
|
244
|
+
rule = str(style.get("height_rule", "atLeast"))
|
|
245
|
+
if rule not in {"atLeast", "exact", "auto"}:
|
|
246
|
+
raise ValueError("height_rule must be atLeast, exact, or auto")
|
|
247
|
+
height.set(qn("w:hRule"), rule)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _set_column_width(table: Any, model: TableModel, column: int, width: int) -> None:
|
|
251
|
+
if column > model.column_count:
|
|
252
|
+
raise ValueError(f"column {column} exceeds table width {model.column_count}")
|
|
253
|
+
grid = table.find(qn("w:tblGrid"))
|
|
254
|
+
columns = list(grid.iterchildren(tag=qn("w:gridCol"))) if grid is not None else []
|
|
255
|
+
if len(columns) < model.column_count:
|
|
256
|
+
raise ValueError("table does not contain a complete tblGrid; column width cannot be edited safely")
|
|
257
|
+
columns[column - 1].set(qn("w:w"), str(width))
|
|
258
|
+
seen: set[int] = set()
|
|
259
|
+
for row in model.rows:
|
|
260
|
+
cell = model.cell_at(row.number, column)
|
|
261
|
+
if cell is None or id(cell.element) in seen or cell.col_span != 1:
|
|
262
|
+
continue
|
|
263
|
+
seen.add(id(cell.element))
|
|
264
|
+
properties = _ensure_first(cell.element, "tcPr")
|
|
265
|
+
target = _ensure(properties, "tcW")
|
|
266
|
+
target.set(qn("w:w"), str(width))
|
|
267
|
+
target.set(qn("w:type"), "dxa")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _merge_cells(table: Any, model: TableModel, operation: dict[str, Any]) -> None:
|
|
271
|
+
first_row, first_col, last_row, last_col = _range(operation, model)
|
|
272
|
+
selected: dict[tuple[int, int], TableCell] = {}
|
|
273
|
+
for row in range(first_row, last_row + 1):
|
|
274
|
+
for column in range(first_col, last_col + 1):
|
|
275
|
+
cell = model.cell_at(row, column)
|
|
276
|
+
if cell is None or cell.row_span != 1 or cell.col_span != 1:
|
|
277
|
+
raise ValueError("merge_cells requires existing unmerged cells across the complete range")
|
|
278
|
+
selected[(row, column)] = cell
|
|
279
|
+
|
|
280
|
+
width = last_col - first_col + 1
|
|
281
|
+
for row in range(first_row, last_row + 1):
|
|
282
|
+
left = selected[(row, first_col)].element
|
|
283
|
+
properties = _ensure_first(left, "tcPr")
|
|
284
|
+
if width > 1:
|
|
285
|
+
_ensure(properties, "gridSpan").set(qn("w:val"), str(width))
|
|
286
|
+
if last_row > first_row:
|
|
287
|
+
_ensure(properties, "vMerge").set(
|
|
288
|
+
qn("w:val"), "restart" if row == first_row else "continue"
|
|
289
|
+
)
|
|
290
|
+
if row > first_row:
|
|
291
|
+
_set_cell_content(left, {"text": ""})
|
|
292
|
+
row_element = selected[(row, first_col)].element.getparent()
|
|
293
|
+
for column in range(first_col + 1, last_col + 1):
|
|
294
|
+
row_element.remove(selected[(row, column)].element)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _unmerge_cells(table: Any, model: TableModel, operation: dict[str, Any]) -> None:
|
|
298
|
+
anchor = _cell(model, operation)
|
|
299
|
+
if anchor.row_span == 1 and anchor.col_span == 1:
|
|
300
|
+
raise ValueError("target cell is not merged")
|
|
301
|
+
width = anchor.col_span
|
|
302
|
+
for row_number in range(anchor.row, anchor.row + anchor.row_span):
|
|
303
|
+
physical = _physical_cell_at(_row(model, row_number).element, anchor.column)
|
|
304
|
+
if physical is None:
|
|
305
|
+
raise ValueError(f"cannot locate merged cell continuation at row {row_number}")
|
|
306
|
+
cell_element, _, _ = physical
|
|
307
|
+
properties = _ensure_first(cell_element, "tcPr")
|
|
308
|
+
_remove(properties, "gridSpan")
|
|
309
|
+
_remove(properties, "vMerge")
|
|
310
|
+
if row_number > anchor.row:
|
|
311
|
+
_set_cell_content(cell_element, {"text": ""})
|
|
312
|
+
insertion_index = cell_element.getparent().index(cell_element) + 1
|
|
313
|
+
for _ in range(width - 1):
|
|
314
|
+
cell_element.getparent().insert(insertion_index, _blank_cell())
|
|
315
|
+
insertion_index += 1
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _insert_rows(table: Any, model: TableModel, operation: dict[str, Any]) -> None:
|
|
319
|
+
before = _positive(operation, "before")
|
|
320
|
+
count = _positive(operation, "count", default=1)
|
|
321
|
+
if before > len(model.rows) + 1:
|
|
322
|
+
raise ValueError(f"before must be between 1 and {len(model.rows) + 1}")
|
|
323
|
+
for row in model.rows:
|
|
324
|
+
for cell in row.cells:
|
|
325
|
+
if cell.row < before < cell.row + cell.row_span:
|
|
326
|
+
raise ValueError("cannot insert a row through an existing vertical merge")
|
|
327
|
+
values = operation.get("rows", [])
|
|
328
|
+
if values and (not isinstance(values, list) or len(values) != count):
|
|
329
|
+
raise ValueError("rows must contain exactly count row arrays")
|
|
330
|
+
reference = model.rows[before - 1].element if before <= len(model.rows) else None
|
|
331
|
+
for index in range(count):
|
|
332
|
+
row_values = values[index] if values else []
|
|
333
|
+
if not isinstance(row_values, list):
|
|
334
|
+
raise TypeError("each inserted row must be an array")
|
|
335
|
+
row_element = _blank_row(model.column_count, row_values)
|
|
336
|
+
if reference is None:
|
|
337
|
+
table.append(row_element)
|
|
338
|
+
else:
|
|
339
|
+
table.insert(table.index(reference), row_element)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _delete_rows(table: Any, model: TableModel, operation: dict[str, Any]) -> None:
|
|
343
|
+
start = _positive(operation, "row")
|
|
344
|
+
count = _positive(operation, "count", default=1)
|
|
345
|
+
end = start + count - 1
|
|
346
|
+
if end > len(model.rows):
|
|
347
|
+
raise ValueError(f"row range {start}:{end} exceeds table height {len(model.rows)}")
|
|
348
|
+
for row in model.rows:
|
|
349
|
+
for cell in row.cells:
|
|
350
|
+
cell_end = cell.row + cell.row_span - 1
|
|
351
|
+
overlaps = cell.row <= end and cell_end >= start
|
|
352
|
+
contained = cell.row >= start and cell_end <= end
|
|
353
|
+
if overlaps and not contained:
|
|
354
|
+
raise ValueError("cannot partially delete an existing vertical merge")
|
|
355
|
+
for number in range(end, start - 1, -1):
|
|
356
|
+
table.remove(model.rows[number - 1].element)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _insert_columns(table: Any, model: TableModel, operation: dict[str, Any]) -> None:
|
|
360
|
+
before = _positive(operation, "before")
|
|
361
|
+
count = _positive(operation, "count", default=1)
|
|
362
|
+
if before > model.column_count + 1:
|
|
363
|
+
raise ValueError(f"before must be between 1 and {model.column_count + 1}")
|
|
364
|
+
_require_regular_row_offsets(model)
|
|
365
|
+
for row in model.rows:
|
|
366
|
+
for cell in row.cells:
|
|
367
|
+
if cell.column < before < cell.column + cell.col_span:
|
|
368
|
+
raise ValueError("cannot insert a column through an existing horizontal merge")
|
|
369
|
+
grid = _complete_grid(table, model)
|
|
370
|
+
reference_width = grid[min(before - 1, len(grid) - 1)].get(qn("w:w")) or "2400"
|
|
371
|
+
grid_parent = grid[0].getparent()
|
|
372
|
+
grid_index = grid_parent.index(grid[before - 1]) if before <= len(grid) else len(grid_parent)
|
|
373
|
+
for _ in range(count):
|
|
374
|
+
column = etree.Element(qn("w:gridCol"))
|
|
375
|
+
column.set(qn("w:w"), reference_width)
|
|
376
|
+
grid_parent.insert(grid_index, column)
|
|
377
|
+
grid_index += 1
|
|
378
|
+
for row in model.rows:
|
|
379
|
+
entries = _physical_cells(row.element)
|
|
380
|
+
insertion_index = len(row.element)
|
|
381
|
+
for cell_element, start, _ in entries:
|
|
382
|
+
if start >= before:
|
|
383
|
+
insertion_index = row.element.index(cell_element)
|
|
384
|
+
break
|
|
385
|
+
for _ in range(count):
|
|
386
|
+
row.element.insert(insertion_index, _blank_cell(reference_width))
|
|
387
|
+
insertion_index += 1
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _delete_columns(table: Any, model: TableModel, operation: dict[str, Any]) -> None:
|
|
391
|
+
start = _positive(operation, "column")
|
|
392
|
+
count = _positive(operation, "count", default=1)
|
|
393
|
+
end = start + count - 1
|
|
394
|
+
if end > model.column_count:
|
|
395
|
+
raise ValueError(f"column range {start}:{end} exceeds table width {model.column_count}")
|
|
396
|
+
_require_regular_row_offsets(model)
|
|
397
|
+
for row in model.rows:
|
|
398
|
+
for cell in row.cells:
|
|
399
|
+
cell_end = cell.column + cell.col_span - 1
|
|
400
|
+
overlaps = cell.column <= end and cell_end >= start
|
|
401
|
+
contained = cell.column >= start and cell_end <= end
|
|
402
|
+
if overlaps and not contained:
|
|
403
|
+
raise ValueError("cannot partially delete an existing horizontal merge")
|
|
404
|
+
for row in model.rows:
|
|
405
|
+
for cell_element, column, span in reversed(_physical_cells(row.element)):
|
|
406
|
+
if column >= start and column + span - 1 <= end:
|
|
407
|
+
row.element.remove(cell_element)
|
|
408
|
+
grid = _complete_grid(table, model)
|
|
409
|
+
parent = grid[0].getparent()
|
|
410
|
+
for column in reversed(grid[start - 1 : end]):
|
|
411
|
+
parent.remove(column)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _range(operation: dict[str, Any], model: TableModel) -> tuple[int, int, int, int]:
|
|
415
|
+
first_row = _positive(operation, "start_row")
|
|
416
|
+
first_col = _positive(operation, "start_column")
|
|
417
|
+
last_row = _positive(operation, "end_row")
|
|
418
|
+
last_col = _positive(operation, "end_column")
|
|
419
|
+
if first_row > last_row or first_col > last_col:
|
|
420
|
+
raise ValueError("range start must be above and to the left of range end")
|
|
421
|
+
if last_row > len(model.rows) or last_col > model.column_count:
|
|
422
|
+
raise ValueError("range exceeds table bounds")
|
|
423
|
+
return first_row, first_col, last_row, last_col
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _physical_cells(row_element: Any) -> list[tuple[Any, int, int]]:
|
|
427
|
+
properties = row_element.find(qn("w:trPr"))
|
|
428
|
+
column = _integer_child(properties, "gridBefore", 0) + 1
|
|
429
|
+
result = []
|
|
430
|
+
for cell in row_element.iterchildren(tag=qn("w:tc")):
|
|
431
|
+
span = max(_integer_child(cell.find(qn("w:tcPr")), "gridSpan", 1), 1)
|
|
432
|
+
result.append((cell, column, span))
|
|
433
|
+
column += span
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _physical_cell_at(row_element: Any, column: int) -> tuple[Any, int, int] | None:
|
|
438
|
+
for entry in _physical_cells(row_element):
|
|
439
|
+
if entry[1] <= column < entry[1] + entry[2]:
|
|
440
|
+
return entry
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _blank_row(columns: int, values: list[Any]) -> Any:
|
|
445
|
+
row = etree.Element(qn("w:tr"))
|
|
446
|
+
for column in range(columns):
|
|
447
|
+
cell = _blank_cell()
|
|
448
|
+
_set_cell_content(cell, {"value": values[column] if column < len(values) else ""})
|
|
449
|
+
row.append(cell)
|
|
450
|
+
return row
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _blank_cell(width: str | None = None) -> Any:
|
|
454
|
+
cell = etree.Element(qn("w:tc"))
|
|
455
|
+
properties = etree.SubElement(cell, qn("w:tcPr"))
|
|
456
|
+
if width:
|
|
457
|
+
target = etree.SubElement(properties, qn("w:tcW"))
|
|
458
|
+
target.set(qn("w:w"), str(width))
|
|
459
|
+
target.set(qn("w:type"), "dxa")
|
|
460
|
+
paragraph = etree.SubElement(cell, qn("w:p"))
|
|
461
|
+
etree.SubElement(paragraph, qn("w:r"))
|
|
462
|
+
return cell
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _complete_grid(table: Any, model: TableModel) -> list[Any]:
|
|
466
|
+
grid = table.find(qn("w:tblGrid"))
|
|
467
|
+
columns = list(grid.iterchildren(tag=qn("w:gridCol"))) if grid is not None else []
|
|
468
|
+
if len(columns) != model.column_count:
|
|
469
|
+
raise ValueError("column operation requires a complete tblGrid")
|
|
470
|
+
return columns
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _require_regular_row_offsets(model: TableModel) -> None:
|
|
474
|
+
for row in model.rows:
|
|
475
|
+
properties = row.element.find(qn("w:trPr"))
|
|
476
|
+
if _integer_child(properties, "gridBefore", 0) or _integer_child(properties, "gridAfter", 0):
|
|
477
|
+
raise ValueError("column operation is not supported on rows with gridBefore/gridAfter")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _integer_child(parent: Any, name: str, default: int) -> int:
|
|
481
|
+
child = parent.find(qn(f"w:{name}")) if parent is not None else None
|
|
482
|
+
try:
|
|
483
|
+
return int(child.get(qn("w:val"))) if child is not None else default
|
|
484
|
+
except (TypeError, ValueError):
|
|
485
|
+
return default
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _ensure_first(parent: Any, name: str) -> Any:
|
|
489
|
+
child = parent.find(qn(f"w:{name}"))
|
|
490
|
+
if child is None:
|
|
491
|
+
child = etree.Element(qn(f"w:{name}"))
|
|
492
|
+
parent.insert(0, child)
|
|
493
|
+
return child
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _ensure(parent: Any, name: str) -> Any:
|
|
497
|
+
child = parent.find(qn(f"w:{name}"))
|
|
498
|
+
if child is None:
|
|
499
|
+
child = etree.SubElement(parent, qn(f"w:{name}"))
|
|
500
|
+
return child
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _remove(parent: Any, name: str) -> None:
|
|
504
|
+
child = parent.find(qn(f"w:{name}"))
|
|
505
|
+
if child is not None:
|
|
506
|
+
parent.remove(child)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _toggle(parent: Any, name: str, enabled: bool) -> None:
|
|
510
|
+
child = parent.find(qn(f"w:{name}"))
|
|
511
|
+
if enabled and child is None:
|
|
512
|
+
etree.SubElement(parent, qn(f"w:{name}"))
|
|
513
|
+
elif not enabled and child is not None:
|
|
514
|
+
parent.remove(child)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _style(operation: dict[str, Any]) -> dict[str, Any]:
|
|
518
|
+
style = operation.get("style")
|
|
519
|
+
if not isinstance(style, dict):
|
|
520
|
+
raise TypeError("style must be an object")
|
|
521
|
+
return style
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _required(value: dict[str, Any], key: str) -> Any:
|
|
525
|
+
if key not in value:
|
|
526
|
+
raise ValueError(f"{key} is required")
|
|
527
|
+
return value[key]
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _positive(value: dict[str, Any], key: str, *, default: int | None = None) -> int:
|
|
531
|
+
raw = value.get(key, default)
|
|
532
|
+
if raw is None:
|
|
533
|
+
raise ValueError(f"{key} is required")
|
|
534
|
+
return _positive_value(raw, key)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _positive_value(raw: Any, key: str) -> int:
|
|
538
|
+
try:
|
|
539
|
+
value = int(raw)
|
|
540
|
+
except (TypeError, ValueError) as exc:
|
|
541
|
+
raise TypeError(f"{key} must be an integer") from exc
|
|
542
|
+
if value < 1:
|
|
543
|
+
raise ValueError(f"{key} must be at least 1")
|
|
544
|
+
return value
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _non_negative(raw: Any, key: str) -> int:
|
|
548
|
+
try:
|
|
549
|
+
value = int(raw)
|
|
550
|
+
except (TypeError, ValueError) as exc:
|
|
551
|
+
raise TypeError(f"{key} must be an integer") from exc
|
|
552
|
+
if value < 0:
|
|
553
|
+
raise ValueError(f"{key} must not be negative")
|
|
554
|
+
return value
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _color(raw: Any) -> str:
|
|
558
|
+
value = str(raw).lstrip("#")
|
|
559
|
+
if not _HEX_COLOR.fullmatch(value):
|
|
560
|
+
raise ValueError(f"invalid RGB color: {raw!r}")
|
|
561
|
+
return value.upper()
|