licos-dev-sdk 0.4.1__tar.gz → 0.4.2__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.4.1 → licos_dev_sdk-0.4.2}/.gitignore +4 -0
- licos_dev_sdk-0.4.2/DOCX.md +60 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/PKG-INFO +1 -1
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/pyproject.toml +1 -1
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/__init__.py +10 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx_edit.py +30 -40
- licos_dev_sdk-0.4.2/src/licos_dev_sdk/document_docx_package.py +57 -0
- licos_dev_sdk-0.4.2/src/licos_dev_sdk/document_docx_structure.py +219 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx_table.py +1 -0
- licos_dev_sdk-0.4.2/src/licos_dev_sdk/document_docx_table_actions.py +33 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_read.py +6 -1
- licos_dev_sdk-0.4.2/tests/test_docx_structure.py +204 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_common.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx_layout.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_table_export.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/model.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/observability.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/pdf_inspect.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/presentation_canvas.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/presentation_inspect.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_document_read.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_document_spreadsheet.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_document_table_export.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_docx_table_edit.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_model.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_observability.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_output_paths.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_ppt_master_runtime.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_presentation.py +0 -0
- {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_spreadsheet_html_edit.py +0 -0
|
@@ -34,6 +34,8 @@ crates/industrial/industrial-stack.env
|
|
|
34
34
|
*.log
|
|
35
35
|
*.pid
|
|
36
36
|
.licos
|
|
37
|
+
# Keep project template metadata in version control.
|
|
38
|
+
!/tools/licos-ai-cli/lib/__templates__/*/.licos
|
|
37
39
|
.tmp
|
|
38
40
|
.playwright-cli
|
|
39
41
|
|
|
@@ -41,6 +43,8 @@ crates/industrial/industrial-stack.env
|
|
|
41
43
|
crates/industrial/bin/
|
|
42
44
|
|
|
43
45
|
tools/android-sdk-cache/*.zip
|
|
46
|
+
tools/android-sdk-cache/*.tar.xz
|
|
47
|
+
tools/android-sdk-cache/*.tar.gz
|
|
44
48
|
tools/codegraph-cache/*.tgz
|
|
45
49
|
tools/codegraph-cache/*.tgz
|
|
46
50
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Word SDK / CLI 0.4.2
|
|
2
|
+
|
|
3
|
+
Cloud document execution stays in `licos-dev-sdk` and `licos-dev`. CLI commands
|
|
4
|
+
only parse arguments and call the SDK; no additional Agent-native Word tools are
|
|
5
|
+
registered. Existing `docx`, `docx-template`, `docx-read`, `docx-edit` commands and
|
|
6
|
+
their Python APIs retain their invocation contracts.
|
|
7
|
+
|
|
8
|
+
| CLI command | SDK function | Input |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `docx-structure` | `read_docx_structure` | File, blocks/paragraphs, page |
|
|
11
|
+
| `docx-target` | `read_docx_target` | File, exact target, text cursor |
|
|
12
|
+
| `docx-replace` | `replace_docx_text` | Source/output, revision, target, search/replacement |
|
|
13
|
+
| `docx-delete-blocks` | `delete_docx_blocks` | Source/output, revision, inclusive block range |
|
|
14
|
+
| `docx-edit-structure` | `edit_docx_structure` | Source/output, revision, small operations array |
|
|
15
|
+
| `docx-table-set` | `set_docx_cell` | Source/output, revision, table/row/column, text |
|
|
16
|
+
| `docx-table-insert-rows` | `insert_docx_rows` | Source/output, revision, table, insertion point, template row, rows |
|
|
17
|
+
| `docx-verify` | `verify_docx` | File, optional positional assertions |
|
|
18
|
+
|
|
19
|
+
## Contract
|
|
20
|
+
|
|
21
|
+
- `target` has exactly one shape: `{"block":1}`, `{"paragraph":1}`, or
|
|
22
|
+
`{"table":1,"row":1,"column":1}`. Indices start at 1. Paragraph indices include
|
|
23
|
+
empty paragraphs and paragraphs inside tables; a body table occupies one block.
|
|
24
|
+
A merged cell must use its anchor coordinate. Existing `docx-read.paragraph`
|
|
25
|
+
is a legacy display ordinal; use its new `target` for editing.
|
|
26
|
+
- `revision` is the SHA256 of the source `word/document.xml` bytes. Reads and edits
|
|
27
|
+
return it. Every new editing entry point requires the source revision; the
|
|
28
|
+
next batch uses the output and its returned revision.
|
|
29
|
+
- Batch structure operations contain only `replace_text` with `target/search/replace`
|
|
30
|
+
or `delete_blocks` with `from/to`. All targets refer to the input snapshot.
|
|
31
|
+
Invalid, missing, duplicate or removed targets fail before publishing output.
|
|
32
|
+
- `docx-replace` expects one exact match within one paragraph in the selected
|
|
33
|
+
target. It supports text split across runs and preserves surrounding formatting.
|
|
34
|
+
`--replace-file` carries long replacement text; simple replacements need no JSON.
|
|
35
|
+
- `docx-table-insert-rows --input rows.json` accepts scalar row arrays such as
|
|
36
|
+
`[["pump","set",2],["valve",null,3]]`. Row count is derived; each merged physical
|
|
37
|
+
cell is filled once. Row/cell/paragraph/run styles come from `--template-row`.
|
|
38
|
+
Templates that intersect vertical merges are rejected explicitly.
|
|
39
|
+
- New editing commands default to refusing an existing output, including the
|
|
40
|
+
source itself. `--overwrite` explicitly allows replacement. Body edits are
|
|
41
|
+
atomic and keep all other ZIP part payloads unchanged. Existing `docx-edit`
|
|
42
|
+
retains its existing overwrite behavior and additionally supports `template_row`.
|
|
43
|
+
- Structure previews set `content_truncated`; `docx-target` retrieves exact text
|
|
44
|
+
without stripping whitespace and provides `next_text_offset` until `has_more=false`.
|
|
45
|
+
No partial JSON repair or automatic argument completion is performed.
|
|
46
|
+
- Verification without assertions checks structure only: `content_verified=false`.
|
|
47
|
+
Positional assertions use `equals`, `contains` or `absent`. A mismatch produces
|
|
48
|
+
`verified=false` and CLI exit code 2. Layout and page breaks require visual review.
|
|
49
|
+
|
|
50
|
+
## Verification
|
|
51
|
+
|
|
52
|
+
Run Python tests serially with the current source on `PYTHONPATH`:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
python -m pytest tools/licos-dev-sdk/tests/test_docx_structure.py tools/licos-dev-cli/tests/test_docx_commands.py
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Runtime acceptance must exercise skill discovery, file input, CLI execution and
|
|
59
|
+
independent DOCX verification through both AgentRuntime and CodexRuntime. The CLI
|
|
60
|
+
help size check measures discoverability; it is not a universal model schema limit.
|
|
@@ -29,6 +29,14 @@ def __getattr__(name: str):
|
|
|
29
29
|
"create_docx": ("document", "create_docx"),
|
|
30
30
|
"create_docx_from_template": ("document", "create_docx_from_template"),
|
|
31
31
|
"edit_docx": ("document", "edit_docx"),
|
|
32
|
+
"read_docx_structure": ("document_docx_structure", "read_docx_structure"),
|
|
33
|
+
"read_docx_target": ("document_docx_structure", "read_docx_target"),
|
|
34
|
+
"edit_docx_structure": ("document_docx_structure", "edit_docx_structure"),
|
|
35
|
+
"replace_docx_text": ("document_docx_structure", "replace_docx_text"),
|
|
36
|
+
"delete_docx_blocks": ("document_docx_structure", "delete_docx_blocks"),
|
|
37
|
+
"verify_docx": ("document_docx_structure", "verify_docx"),
|
|
38
|
+
"set_docx_cell": ("document_docx_table_actions", "set_docx_cell"),
|
|
39
|
+
"insert_docx_rows": ("document_docx_table_actions", "insert_docx_rows"),
|
|
32
40
|
"read_pdf": ("document_read", "read_pdf"),
|
|
33
41
|
"read_docx": ("document_read", "read_docx"),
|
|
34
42
|
"read_xlsx": ("document_read", "read_xlsx"),
|
|
@@ -100,6 +108,8 @@ def __getattr__(name: str):
|
|
|
100
108
|
|
|
101
109
|
|
|
102
110
|
__all__ = [
|
|
111
|
+
"read_docx_structure", "read_docx_target", "edit_docx_structure",
|
|
112
|
+
"replace_docx_text", "delete_docx_blocks", "verify_docx", "set_docx_cell", "insert_docx_rows",
|
|
103
113
|
"create_json", "create_xml", "create_yaml",
|
|
104
114
|
"create_zip", "create_tar_gz",
|
|
105
115
|
"create_qrcode", "create_barcode", "create_watermark",
|
|
@@ -4,19 +4,16 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from copy import deepcopy
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
import os
|
|
8
7
|
import re
|
|
9
|
-
import tempfile
|
|
10
8
|
from typing import Any
|
|
11
|
-
from zipfile import ZIP_DEFLATED, ZipFile
|
|
12
9
|
|
|
13
10
|
from docx.oxml.ns import qn
|
|
14
11
|
from lxml import etree
|
|
15
12
|
|
|
16
13
|
from .document_docx_table import TableCell, TableModel, parse_table
|
|
14
|
+
from .document_docx_package import DocxPackage
|
|
17
15
|
|
|
18
16
|
|
|
19
|
-
_DOCUMENT_XML = "word/document.xml"
|
|
20
17
|
_HEX_COLOR = re.compile(r"^[0-9A-Fa-f]{6}$")
|
|
21
18
|
|
|
22
19
|
|
|
@@ -24,6 +21,9 @@ def edit_docx_tables(
|
|
|
24
21
|
source: str | Path,
|
|
25
22
|
output: str | Path,
|
|
26
23
|
operations: list[dict[str, Any]],
|
|
24
|
+
*,
|
|
25
|
+
revision: str | None = None,
|
|
26
|
+
overwrite: bool = True,
|
|
27
27
|
) -> str:
|
|
28
28
|
"""Apply explicit table operations to a DOCX package."""
|
|
29
29
|
|
|
@@ -38,42 +38,16 @@ def edit_docx_tables(
|
|
|
38
38
|
if not all(isinstance(operation, dict) for operation in operations):
|
|
39
39
|
raise TypeError("every DOCX edit operation must be an object")
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
|
41
|
+
package = DocxPackage(source_path)
|
|
42
|
+
if revision is not None:
|
|
43
|
+
package.require_revision(revision)
|
|
44
|
+
for operation_number, operation in enumerate(operations, start=1):
|
|
62
45
|
try:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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)
|
|
46
|
+
_apply_operation(package.root, operation)
|
|
47
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
48
|
+
name = operation.get("op", "<missing>")
|
|
49
|
+
raise ValueError(f"DOCX operation {operation_number} ({name}) failed: {exc}") from exc
|
|
50
|
+
package.save(output_path, overwrite=overwrite)
|
|
77
51
|
return str(output_path)
|
|
78
52
|
|
|
79
53
|
|
|
@@ -327,12 +301,28 @@ def _insert_rows(table: Any, model: TableModel, operation: dict[str, Any]) -> No
|
|
|
327
301
|
values = operation.get("rows", [])
|
|
328
302
|
if values and (not isinstance(values, list) or len(values) != count):
|
|
329
303
|
raise ValueError("rows must contain exactly count row arrays")
|
|
304
|
+
template = None
|
|
305
|
+
if "template_row" in operation:
|
|
306
|
+
template = _row(model, _positive(operation, "template_row")).element
|
|
307
|
+
if any(cell.row_span > 1 for row in model.rows for cell in row.cells
|
|
308
|
+
if cell.row <= operation["template_row"] < cell.row + cell.row_span):
|
|
309
|
+
raise ValueError("template_row cannot intersect a vertical merge")
|
|
330
310
|
reference = model.rows[before - 1].element if before <= len(model.rows) else None
|
|
331
311
|
for index in range(count):
|
|
332
312
|
row_values = values[index] if values else []
|
|
333
313
|
if not isinstance(row_values, list):
|
|
334
314
|
raise TypeError("each inserted row must be an array")
|
|
335
|
-
|
|
315
|
+
if template is None:
|
|
316
|
+
row_element = _blank_row(model.column_count, row_values)
|
|
317
|
+
else:
|
|
318
|
+
row_element = deepcopy(template)
|
|
319
|
+
cells = list(row_element.iterchildren(qn("w:tc")))
|
|
320
|
+
if len(row_values) != len(cells):
|
|
321
|
+
raise ValueError("template rows must provide one value per physical cell; merged cells are filled once")
|
|
322
|
+
for cell, value in zip(cells, row_values):
|
|
323
|
+
if value is not None and type(value) not in (str, int, float, bool):
|
|
324
|
+
raise TypeError("DOCX row values must be strings, numbers, booleans or null")
|
|
325
|
+
_set_cell_content(cell, {"value": "" if value is None else value})
|
|
336
326
|
if reference is None:
|
|
337
327
|
table.append(row_element)
|
|
338
328
|
else:
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Read and replace the DOCX body without rewriting other package parts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
from zipfile import ZipFile
|
|
10
|
+
|
|
11
|
+
from lxml import etree
|
|
12
|
+
|
|
13
|
+
DOCUMENT_XML = "word/document.xml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DocxPackage:
|
|
17
|
+
def __init__(self, source: str | Path):
|
|
18
|
+
self.path = Path(source).expanduser().resolve()
|
|
19
|
+
if self.path.suffix.lower() != ".docx":
|
|
20
|
+
raise ValueError("source must be a .docx file")
|
|
21
|
+
with ZipFile(self.path) as archive:
|
|
22
|
+
self.comment = archive.comment
|
|
23
|
+
self.parts = [(info, archive.read(info.filename)) for info in archive.infolist()]
|
|
24
|
+
xml = next((data for info, data in self.parts if info.filename == DOCUMENT_XML), None)
|
|
25
|
+
if xml is None:
|
|
26
|
+
raise ValueError("DOCX package does not contain word/document.xml")
|
|
27
|
+
self.revision = sha256(xml).hexdigest()
|
|
28
|
+
self.root = etree.fromstring(xml, etree.XMLParser(resolve_entities=False, no_network=True))
|
|
29
|
+
|
|
30
|
+
def require_revision(self, revision: str) -> None:
|
|
31
|
+
if revision != self.revision:
|
|
32
|
+
raise ValueError("document revision changed; read the source again and use its current revision")
|
|
33
|
+
|
|
34
|
+
def save(self, output: str | Path, *, overwrite: bool = False) -> dict:
|
|
35
|
+
path = Path(output).expanduser().resolve()
|
|
36
|
+
if path.suffix.lower() != ".docx":
|
|
37
|
+
raise ValueError("output must be a .docx file")
|
|
38
|
+
if path.exists() and not overwrite:
|
|
39
|
+
raise FileExistsError(f"output exists: {path}; explicitly enable overwrite to replace it")
|
|
40
|
+
xml = etree.tostring(self.root, xml_declaration=True, encoding="UTF-8", standalone=True)
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
handle, temporary = tempfile.mkstemp(prefix=f".{path.stem}-", suffix=".docx", dir=path.parent)
|
|
43
|
+
os.close(handle)
|
|
44
|
+
temporary_path = Path(temporary)
|
|
45
|
+
try:
|
|
46
|
+
with ZipFile(temporary_path, "w") as archive:
|
|
47
|
+
archive.comment = self.comment
|
|
48
|
+
for info, data in self.parts:
|
|
49
|
+
archive.writestr(info, xml if info.filename == DOCUMENT_XML else data)
|
|
50
|
+
if overwrite:
|
|
51
|
+
os.replace(temporary_path, path)
|
|
52
|
+
else:
|
|
53
|
+
# Publish atomically without overwriting a concurrently created output.
|
|
54
|
+
os.link(temporary_path, path)
|
|
55
|
+
finally:
|
|
56
|
+
temporary_path.unlink(missing_ok=True)
|
|
57
|
+
return {"path": str(path), "revision": sha256(xml).hexdigest()}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Small, revision-bound DOCX operations for SDK and CLI callers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from docx.oxml.ns import qn
|
|
10
|
+
from lxml import etree
|
|
11
|
+
|
|
12
|
+
from .document_docx_package import DocxPackage
|
|
13
|
+
from .document_docx_table import parse_table
|
|
14
|
+
|
|
15
|
+
TEXT_TAGS = {qn("w:t"): None, qn("w:tab"): "\t", qn("w:br"): "\n", qn("w:cr"): "\n"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _index(value: int, name: str, minimum: int = 1) -> int:
|
|
19
|
+
if type(value) is not int or value < minimum:
|
|
20
|
+
raise ValueError(f"{name} must be an integer >= {minimum}")
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _paragraph_text(paragraph: Any) -> str:
|
|
25
|
+
return "".join((node.text or "") if node.tag == qn("w:t") else TEXT_TAGS[node.tag]
|
|
26
|
+
for node in paragraph.iter() if node.tag in TEXT_TAGS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _text(element: Any) -> str:
|
|
30
|
+
return "\n".join(_paragraph_text(p) for p in element.iter(qn("w:p")))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Structure:
|
|
34
|
+
def __init__(self, root: Any):
|
|
35
|
+
self.body = root.find(qn("w:body"))
|
|
36
|
+
if self.body is None:
|
|
37
|
+
raise ValueError("DOCX body is missing")
|
|
38
|
+
self.blocks = [child for child in self.body if child.tag != qn("w:sectPr")]
|
|
39
|
+
self.paragraphs = list(self.body.iter(qn("w:p")))
|
|
40
|
+
self.tables = list(self.body.iterchildren(qn("w:tbl")))
|
|
41
|
+
|
|
42
|
+
def target(self, target: dict[str, int]) -> Any:
|
|
43
|
+
if not isinstance(target, dict):
|
|
44
|
+
raise TypeError("target must be an object")
|
|
45
|
+
keys = set(target)
|
|
46
|
+
if keys == {"block"} or keys == {"paragraph"}:
|
|
47
|
+
kind = next(iter(keys))
|
|
48
|
+
collection = self.blocks if kind == "block" else self.paragraphs
|
|
49
|
+
number = _index(target[kind], kind)
|
|
50
|
+
if number > len(collection):
|
|
51
|
+
raise ValueError(f"{kind} {number} does not exist")
|
|
52
|
+
return collection[number - 1]
|
|
53
|
+
if keys == {"table", "row", "column"}:
|
|
54
|
+
table, row, column = (_index(target[key], key) for key in ("table", "row", "column"))
|
|
55
|
+
if table > len(self.tables):
|
|
56
|
+
raise ValueError(f"table {table} does not exist")
|
|
57
|
+
cell = parse_table(self.tables[table - 1], include_nested=False).cell_at(row, column)
|
|
58
|
+
if cell is None:
|
|
59
|
+
raise ValueError(f"cell {target} does not exist")
|
|
60
|
+
if (cell.row, cell.column) != (row, column):
|
|
61
|
+
raise ValueError(f"use the merged cell anchor: table={table}, row={cell.row}, column={cell.column}")
|
|
62
|
+
return cell.element
|
|
63
|
+
raise ValueError("target must contain only block, only paragraph, or table+row+column")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_docx_structure(source: str | Path, *, scope: str = "blocks", offset: int = 1,
|
|
67
|
+
limit: int = 20, max_chars: int = 200) -> dict:
|
|
68
|
+
"""Read stable one-based targets; paragraph numbering includes table paragraphs."""
|
|
69
|
+
_index(offset, "offset")
|
|
70
|
+
_index(limit, "limit")
|
|
71
|
+
_index(max_chars, "max_chars")
|
|
72
|
+
if scope not in {"blocks", "paragraphs"}:
|
|
73
|
+
raise ValueError("scope must be blocks or paragraphs")
|
|
74
|
+
package = DocxPackage(source)
|
|
75
|
+
structure = _Structure(package.root)
|
|
76
|
+
elements = structure.blocks if scope == "blocks" else structure.paragraphs
|
|
77
|
+
items = []
|
|
78
|
+
for number, element in enumerate(elements[offset - 1:offset - 1 + limit], offset):
|
|
79
|
+
text = _text(element)
|
|
80
|
+
items.append({"target": {scope[:-1]: number}, "type": etree.QName(element).localname,
|
|
81
|
+
"text": text[:max_chars], "content_truncated": len(text) > max_chars})
|
|
82
|
+
next_offset = offset + len(items)
|
|
83
|
+
return {"path": str(package.path), "revision": package.revision, "scope": scope,
|
|
84
|
+
"coordinate_systems": {"block": "top-level body blocks; a table is one block",
|
|
85
|
+
"paragraph": "all body paragraphs, including paragraphs in tables"},
|
|
86
|
+
"block_count": len(structure.blocks), "paragraph_count": len(structure.paragraphs),
|
|
87
|
+
"table_count": len(structure.tables), "items": items, "has_more": next_offset <= len(elements),
|
|
88
|
+
"next_offset": next_offset if next_offset <= len(elements) else None}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_docx_target(source: str | Path, target: dict[str, int], *, text_offset: int = 0,
|
|
92
|
+
max_chars: int = 8000) -> dict:
|
|
93
|
+
"""Read a target's text with an explicit continuation cursor, without stripping whitespace."""
|
|
94
|
+
_index(text_offset, "text_offset", 0)
|
|
95
|
+
_index(max_chars, "max_chars")
|
|
96
|
+
package = DocxPackage(source)
|
|
97
|
+
text = _text(_Structure(package.root).target(target))
|
|
98
|
+
part = text[text_offset:text_offset + max_chars]
|
|
99
|
+
end = text_offset + len(part)
|
|
100
|
+
return {"path": str(package.path), "revision": package.revision, "target": target,
|
|
101
|
+
"text": part, "text_offset": text_offset, "total_characters": len(text),
|
|
102
|
+
"has_more": end < len(text), "next_text_offset": end if end < len(text) else None}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _replacement_nodes(value: str) -> list:
|
|
106
|
+
nodes = []
|
|
107
|
+
for part in re.split("(\n|\t)", value):
|
|
108
|
+
if not part:
|
|
109
|
+
continue
|
|
110
|
+
node = etree.Element(qn("w:br" if part == "\n" else "w:tab" if part == "\t" else "w:t"))
|
|
111
|
+
if part not in {"\n", "\t"}:
|
|
112
|
+
node.text = part
|
|
113
|
+
node.set(qn("xml:space"), "preserve")
|
|
114
|
+
nodes.append(node)
|
|
115
|
+
return nodes
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _replace(target: Any, search: str, replacement: str) -> None:
|
|
119
|
+
if not isinstance(search, str) or not search or not isinstance(replacement, str):
|
|
120
|
+
raise ValueError("search must be a non-empty string and replace must be a string")
|
|
121
|
+
matches = []
|
|
122
|
+
for paragraph in target.iter(qn("w:p")):
|
|
123
|
+
text = _paragraph_text(paragraph)
|
|
124
|
+
start = text.find(search)
|
|
125
|
+
while start >= 0:
|
|
126
|
+
matches.append((paragraph, start))
|
|
127
|
+
start = text.find(search, start + 1)
|
|
128
|
+
if len(matches) != 1:
|
|
129
|
+
raise ValueError(f"search must match exactly once within one target paragraph; found {len(matches)}; "
|
|
130
|
+
f"target text={_text(target)[:200]!r}")
|
|
131
|
+
paragraph, start = matches[0]
|
|
132
|
+
end, offset, inserted = start + len(search), 0, False
|
|
133
|
+
for node in [n for n in paragraph.iter() if n.tag in TEXT_TAGS]:
|
|
134
|
+
value = (node.text or "") if node.tag == qn("w:t") else TEXT_TAGS[node.tag]
|
|
135
|
+
next_offset = offset + len(value)
|
|
136
|
+
if next_offset > start and offset < end:
|
|
137
|
+
new_value = value[:max(0, start - offset)] + ("" if inserted else replacement) + value[min(len(value), end - offset):]
|
|
138
|
+
parent, index = node.getparent(), node.getparent().index(node)
|
|
139
|
+
parent.remove(node)
|
|
140
|
+
for added in _replacement_nodes(new_value):
|
|
141
|
+
parent.insert(index, added)
|
|
142
|
+
index += 1
|
|
143
|
+
inserted = True
|
|
144
|
+
offset = next_offset
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def edit_docx_structure(source: str | Path, output: str | Path, *, revision: str,
|
|
148
|
+
operations: list[dict], overwrite: bool = False) -> dict:
|
|
149
|
+
"""Apply up to 200 edits against one read snapshot, atomically preserving other parts."""
|
|
150
|
+
if not isinstance(operations, list) or not 1 <= len(operations) <= 200:
|
|
151
|
+
raise ValueError("operations must be an array with 1-200 entries")
|
|
152
|
+
package = DocxPackage(source)
|
|
153
|
+
package.require_revision(revision)
|
|
154
|
+
structure = _Structure(package.root)
|
|
155
|
+
resolved = []
|
|
156
|
+
for index, operation in enumerate(operations, 1):
|
|
157
|
+
if not isinstance(operation, dict):
|
|
158
|
+
raise TypeError(f"operation {index} must be an object")
|
|
159
|
+
if operation.get("op") == "replace_text" and set(operation) == {"op", "target", "search", "replace"}:
|
|
160
|
+
resolved.append((operation, [structure.target(operation["target"])]))
|
|
161
|
+
elif operation.get("op") == "delete_blocks" and set(operation) == {"op", "from", "to"}:
|
|
162
|
+
first, last = _index(operation["from"], "from"), _index(operation["to"], "to")
|
|
163
|
+
if first > last or last > len(structure.blocks):
|
|
164
|
+
raise ValueError(f"operation {index}: invalid block range")
|
|
165
|
+
resolved.append((operation, structure.blocks[first - 1:last]))
|
|
166
|
+
else:
|
|
167
|
+
raise ValueError(f"operation {index}: expected replace_text(op,target,search,replace) or delete_blocks(op,from,to)")
|
|
168
|
+
for index, (operation, elements) in enumerate(resolved, 1):
|
|
169
|
+
try:
|
|
170
|
+
for element in elements:
|
|
171
|
+
if structure.body not in element.iterancestors():
|
|
172
|
+
raise ValueError("target was removed by an earlier operation")
|
|
173
|
+
if operation["op"] == "replace_text":
|
|
174
|
+
_replace(element, operation["search"], operation["replace"])
|
|
175
|
+
else:
|
|
176
|
+
if next(element.iter(qn("w:sectPr")), None) is not None:
|
|
177
|
+
raise ValueError("block contains section properties; deleting it would change section layout")
|
|
178
|
+
structure.body.remove(element)
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
raise ValueError(f"operation {index} ({operation['op']}), target={operation.get('target')}: {exc}") from exc
|
|
181
|
+
return {**package.save(output, overwrite=overwrite), "applied_operations": len(operations)}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def replace_docx_text(source: str | Path, output: str | Path, *, revision: str,
|
|
185
|
+
target: dict[str, int], search: str, replacement: str, overwrite: bool = False) -> dict:
|
|
186
|
+
return edit_docx_structure(source, output, revision=revision, overwrite=overwrite,
|
|
187
|
+
operations=[{"op": "replace_text", "target": target, "search": search, "replace": replacement}])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def delete_docx_blocks(source: str | Path, output: str | Path, *, revision: str,
|
|
191
|
+
first: int, last: int, overwrite: bool = False) -> dict:
|
|
192
|
+
return edit_docx_structure(source, output, revision=revision, overwrite=overwrite,
|
|
193
|
+
operations=[{"op": "delete_blocks", "from": first, "to": last}])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def verify_docx(source: str | Path, *, expectations: list[dict] | None = None) -> dict:
|
|
197
|
+
"""Check package structure and exact positions; absent assertions do not certify content."""
|
|
198
|
+
package = DocxPackage(source)
|
|
199
|
+
structure = _Structure(package.root)
|
|
200
|
+
for table in structure.tables:
|
|
201
|
+
parse_table(table)
|
|
202
|
+
expectations = [] if expectations is None else expectations
|
|
203
|
+
if not isinstance(expectations, list):
|
|
204
|
+
raise TypeError("expectations must be an array")
|
|
205
|
+
mismatches = []
|
|
206
|
+
for index, expectation in enumerate(expectations, 1):
|
|
207
|
+
if not isinstance(expectation, dict) or not {"target", "text"} <= set(expectation) or set(expectation) - {"target", "text", "mode"}:
|
|
208
|
+
raise ValueError(f"expectation {index} requires target,text and optional mode")
|
|
209
|
+
expected, mode = expectation["text"], expectation.get("mode", "equals")
|
|
210
|
+
if not isinstance(expected, str) or mode not in {"equals", "contains", "absent"}:
|
|
211
|
+
raise ValueError(f"expectation {index}: text must be a string; mode must be equals, contains or absent")
|
|
212
|
+
actual = _text(structure.target(expectation["target"]))
|
|
213
|
+
passed = actual == expected if mode == "equals" else expected in actual if mode == "contains" else expected not in actual
|
|
214
|
+
if not passed:
|
|
215
|
+
mismatches.append({"index": index, "target": expectation["target"], "mode": mode, "expected": expected,
|
|
216
|
+
"actual": actual[:1000], "actual_truncated": len(actual) > 1000})
|
|
217
|
+
return {"path": str(package.path), "revision": package.revision, "structure_verified": True,
|
|
218
|
+
"content_verified": bool(expectations) and not mismatches, "verified": not mismatches,
|
|
219
|
+
"expectation_count": len(expectations), "mismatches": mismatches}
|
|
@@ -197,6 +197,7 @@ def table_row_items(
|
|
|
197
197
|
max_chars=max_chars,
|
|
198
198
|
include_styles=include_styles,
|
|
199
199
|
)
|
|
200
|
+
value["target"] = {"table": table_number, "row": cell.row, "column": cell.column}
|
|
200
201
|
cells.append(value)
|
|
201
202
|
truncated = truncated or cell_truncated
|
|
202
203
|
item: dict[str, Any] = {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Focused table actions with explicit coordinates and revision checks."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .document_docx_edit import _apply_operation
|
|
6
|
+
from .document_docx_package import DocxPackage
|
|
7
|
+
from .document_docx_structure import _Structure, _index
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_docx_cell(source: str | Path, output: str | Path, *, revision: str,
|
|
11
|
+
table: int, row: int, column: int, text: str, overwrite: bool = False) -> dict:
|
|
12
|
+
if not isinstance(text, str):
|
|
13
|
+
raise TypeError("text must be a string")
|
|
14
|
+
package = DocxPackage(source)
|
|
15
|
+
package.require_revision(revision)
|
|
16
|
+
_Structure(package.root).target({"table": table, "row": row, "column": column})
|
|
17
|
+
_apply_operation(package.root, {"op": "set_cell", "table": table, "row": row, "column": column, "text": text})
|
|
18
|
+
return package.save(output, overwrite=overwrite)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def insert_docx_rows(source: str | Path, output: str | Path, *, revision: str,
|
|
22
|
+
table: int, before: int, rows: list[list], template_row: int,
|
|
23
|
+
overwrite: bool = False) -> dict:
|
|
24
|
+
"""Clone a template row for each row of scalar values; count is derived from rows."""
|
|
25
|
+
for name, value in (("table", table), ("before", before), ("template_row", template_row)):
|
|
26
|
+
_index(value, name)
|
|
27
|
+
if not isinstance(rows, list) or not 1 <= len(rows) <= 200:
|
|
28
|
+
raise ValueError("rows must contain 1-200 row arrays")
|
|
29
|
+
package = DocxPackage(source)
|
|
30
|
+
package.require_revision(revision)
|
|
31
|
+
_apply_operation(package.root, {"op": "insert_rows", "table": table, "before": before,
|
|
32
|
+
"template_row": template_row, "count": len(rows), "rows": rows})
|
|
33
|
+
return {**package.save(output, overwrite=overwrite), "inserted_rows": len(rows)}
|
|
@@ -182,6 +182,8 @@ def _read_docx(
|
|
|
182
182
|
from docx.text.paragraph import Paragraph
|
|
183
183
|
|
|
184
184
|
from .document_docx_table import parse_table, table_row_items
|
|
185
|
+
from hashlib import sha256
|
|
186
|
+
from zipfile import ZipFile
|
|
185
187
|
|
|
186
188
|
document = Document(str(path))
|
|
187
189
|
items: list[dict[str, Any]] = []
|
|
@@ -189,7 +191,7 @@ def _read_docx(
|
|
|
189
191
|
paragraph_number = 0
|
|
190
192
|
table_number = 0
|
|
191
193
|
|
|
192
|
-
for child in document.element.body.iterchildren():
|
|
194
|
+
for block_number, child in enumerate(document.element.body.iterchildren(), 1):
|
|
193
195
|
tag = child.tag.rsplit("}", 1)[-1]
|
|
194
196
|
if tag == "p":
|
|
195
197
|
paragraph = Paragraph(child, document)
|
|
@@ -202,6 +204,7 @@ def _read_docx(
|
|
|
202
204
|
{
|
|
203
205
|
"type": "paragraph",
|
|
204
206
|
"paragraph": paragraph_number,
|
|
207
|
+
"target": {"block": block_number},
|
|
205
208
|
"style": paragraph.style.name if paragraph.style is not None else None,
|
|
206
209
|
"text": text,
|
|
207
210
|
"content_truncated": truncated,
|
|
@@ -225,6 +228,8 @@ def _read_docx(
|
|
|
225
228
|
result["paragraph_count"] = paragraph_number
|
|
226
229
|
result["table_count"] = table_number
|
|
227
230
|
result["warnings"] = warnings
|
|
231
|
+
with ZipFile(path) as archive:
|
|
232
|
+
result["revision"] = sha256(archive.read("word/document.xml")).hexdigest()
|
|
228
233
|
return result
|
|
229
234
|
|
|
230
235
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from zipfile import ZipFile
|
|
3
|
+
|
|
4
|
+
from docx import Document
|
|
5
|
+
from docx.oxml import OxmlElement
|
|
6
|
+
from docx.oxml.ns import qn
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from licos_dev_sdk import (
|
|
10
|
+
read_docx, read_docx_structure, read_docx_target, replace_docx_text,
|
|
11
|
+
edit_docx_structure, delete_docx_blocks, verify_docx, set_docx_cell, insert_docx_rows,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def source(tmp_path):
|
|
17
|
+
path = tmp_path / "template.docx"
|
|
18
|
+
doc = Document()
|
|
19
|
+
doc.add_paragraph("")
|
|
20
|
+
p = doc.add_paragraph()
|
|
21
|
+
p.add_run("甲方账号:").bold = True
|
|
22
|
+
p.add_run("12").italic = True
|
|
23
|
+
p.add_run("3456")
|
|
24
|
+
table = doc.add_table(rows=2, cols=3)
|
|
25
|
+
table.cell(0, 0).merge(table.cell(0, 1)).text = "名称"
|
|
26
|
+
table.cell(0, 2).text = "数量"
|
|
27
|
+
table.cell(1, 0).text = "模板"
|
|
28
|
+
table.cell(1, 1).text = "单位"
|
|
29
|
+
table.cell(1, 2).text = "1"
|
|
30
|
+
table.cell(1, 0).paragraphs[0].runs[0].bold = True
|
|
31
|
+
shade = OxmlElement("w:shd")
|
|
32
|
+
shade.set(qn("w:fill"), "AABBCC")
|
|
33
|
+
table.cell(1, 0)._tc.get_or_add_tcPr().append(shade)
|
|
34
|
+
doc.add_paragraph("附录")
|
|
35
|
+
doc.add_paragraph("附件说明")
|
|
36
|
+
doc.sections[0].header.paragraphs[0].text = "保留页眉"
|
|
37
|
+
doc.save(path)
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rev(path):
|
|
42
|
+
return read_docx_structure(path)["revision"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def assert_parts_preserved(source, output):
|
|
46
|
+
with ZipFile(source) as before, ZipFile(output) as after:
|
|
47
|
+
assert before.namelist() == after.namelist()
|
|
48
|
+
for name in before.namelist():
|
|
49
|
+
if name != "word/document.xml":
|
|
50
|
+
assert before.read(name) == after.read(name), name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_targets_distinguish_blocks_empty_paragraphs_and_table_paragraphs(source):
|
|
54
|
+
blocks = read_docx_structure(source, limit=3)
|
|
55
|
+
assert blocks["items"][0]["target"] == {"block": 1}
|
|
56
|
+
assert blocks["items"][2]["type"] == "tbl"
|
|
57
|
+
assert blocks["next_offset"] == 4
|
|
58
|
+
paragraphs = read_docx_structure(source, scope="paragraphs", offset=3, limit=1)
|
|
59
|
+
assert paragraphs["items"][0]["text"] == "名称"
|
|
60
|
+
assert paragraphs["items"][0]["target"] == {"paragraph": 3}
|
|
61
|
+
legacy = read_docx(source)
|
|
62
|
+
assert legacy["items"][0]["paragraph"] == 1 # preserve existing read numbering
|
|
63
|
+
assert legacy["items"][0]["target"] == {"block": 2}
|
|
64
|
+
assert legacy["items"][1]["cells"][0]["target"] == {"table": 1, "row": 1, "column": 1}
|
|
65
|
+
assert legacy["revision"] == blocks["revision"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_replace_spanning_runs_preserves_formatting_original_and_other_parts(source, tmp_path):
|
|
69
|
+
original = source.read_bytes()
|
|
70
|
+
output = tmp_path / "filled.docx"
|
|
71
|
+
report = replace_docx_text(source, output, revision=rev(source), target={"block": 2},
|
|
72
|
+
search="123456", replacement="998877")
|
|
73
|
+
p = Document(output).paragraphs[1]
|
|
74
|
+
assert p.text == "甲方账号:998877"
|
|
75
|
+
assert p.runs[0].bold and p.runs[1].italic
|
|
76
|
+
assert report["revision"] == rev(output) != rev(source)
|
|
77
|
+
assert source.read_bytes() == original
|
|
78
|
+
assert_parts_preserved(source, output)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_batch_targets_refer_to_input_snapshot_and_reject_removed_targets(source, tmp_path):
|
|
82
|
+
output = tmp_path / "batch.docx"
|
|
83
|
+
edit_docx_structure(source, output, revision=rev(source), operations=[
|
|
84
|
+
{"op": "delete_blocks", "from": 1, "to": 1},
|
|
85
|
+
{"op": "replace_text", "target": {"block": 2}, "search": "123456", "replace": "新账号"},
|
|
86
|
+
])
|
|
87
|
+
assert Document(output).paragraphs[0].text == "甲方账号:新账号"
|
|
88
|
+
with pytest.raises(ValueError, match="removed"):
|
|
89
|
+
edit_docx_structure(source, tmp_path / "invalid.docx", revision=rev(source), operations=[
|
|
90
|
+
{"op": "delete_blocks", "from": 1, "to": 2},
|
|
91
|
+
{"op": "replace_text", "target": {"block": 2}, "search": "123456", "replace": "x"},
|
|
92
|
+
])
|
|
93
|
+
assert not (tmp_path / "invalid.docx").exists()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_delete_appendix_and_exact_cell_verification(source, tmp_path):
|
|
97
|
+
output = tmp_path / "no-appendix.docx"
|
|
98
|
+
delete_docx_blocks(source, output, revision=rev(source), first=4, last=5)
|
|
99
|
+
assert [p.text for p in Document(output).paragraphs] == ["", "甲方账号:123456"]
|
|
100
|
+
assert verify_docx(output)["content_verified"] is False
|
|
101
|
+
assert verify_docx(output, expectations=[{"target": {"block": 2}, "text": "123456", "mode": "contains"}])["content_verified"]
|
|
102
|
+
failed = verify_docx(output, expectations=[{"target": {"table": 1, "row": 2, "column": 1}, "text": "123456", "mode": "contains"}])
|
|
103
|
+
assert not failed["verified"]
|
|
104
|
+
assert failed["mismatches"][0]["actual"] == "模板"
|
|
105
|
+
assert_parts_preserved(source, output)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_stale_revision_and_existing_output_leave_files_unchanged(source, tmp_path):
|
|
109
|
+
output = tmp_path / "existing.docx"
|
|
110
|
+
output.write_bytes(b"existing")
|
|
111
|
+
original = source.read_bytes()
|
|
112
|
+
with pytest.raises(ValueError, match="revision"):
|
|
113
|
+
delete_docx_blocks(source, output, revision="old", first=4, last=5, overwrite=True)
|
|
114
|
+
assert output.read_bytes() == b"existing"
|
|
115
|
+
with pytest.raises(FileExistsError):
|
|
116
|
+
delete_docx_blocks(source, source, revision=rev(source), first=4, last=5)
|
|
117
|
+
assert source.read_bytes() == original
|
|
118
|
+
delete_docx_blocks(source, source, revision=rev(source), first=4, last=5, overwrite=True)
|
|
119
|
+
assert read_docx_structure(source)["block_count"] == 3
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.parametrize("target", [{"block": 2, "paragraph": 2}, {"block": True}, {"table": 1, "row": 1}, {"table": 1, "row": 1, "column": 2}])
|
|
123
|
+
def test_invalid_or_non_anchor_targets_are_not_guessed(source, target):
|
|
124
|
+
with pytest.raises(ValueError):
|
|
125
|
+
read_docx_target(source, target)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_exact_text_read_continues_across_long_whitespace_and_unicode(tmp_path):
|
|
129
|
+
source = tmp_path / "long.docx"
|
|
130
|
+
text = " 中文🙂\tA\n" * 90 + " "
|
|
131
|
+
doc = Document()
|
|
132
|
+
doc.add_paragraph(text)
|
|
133
|
+
doc.save(source)
|
|
134
|
+
assert read_docx_structure(source, max_chars=10)["items"][0]["content_truncated"]
|
|
135
|
+
chunks, offset = [], 0
|
|
136
|
+
while True:
|
|
137
|
+
read = read_docx_target(source, {"paragraph": 1}, text_offset=offset, max_chars=37)
|
|
138
|
+
chunks.append(read["text"])
|
|
139
|
+
if not read["has_more"]:
|
|
140
|
+
break
|
|
141
|
+
offset = read["next_text_offset"]
|
|
142
|
+
assert "".join(chunks) == text
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_replace_tabs_newlines_and_empty_replacement(source, tmp_path):
|
|
146
|
+
output = tmp_path / "multiline.docx"
|
|
147
|
+
replace_docx_text(source, output, revision=rev(source), target={"paragraph": 2}, search="123456", replacement="A\tB\nC")
|
|
148
|
+
assert Document(output).paragraphs[1].text == "甲方账号:A\tB\nC"
|
|
149
|
+
replace_docx_text(output, output, revision=rev(output), target={"paragraph": 2}, search="A\tB\nC", replacement="", overwrite=True)
|
|
150
|
+
assert Document(output).paragraphs[1].text == "甲方账号:"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_clone_template_rows_preserves_styles_and_fills_scalars(source, tmp_path):
|
|
154
|
+
output = tmp_path / "rows.docx"
|
|
155
|
+
report = insert_docx_rows(source, output, revision=rev(source), table=1, before=3, template_row=2,
|
|
156
|
+
rows=[["泵", "台", 2], ["阀", None, True]])
|
|
157
|
+
assert report["inserted_rows"] == 2
|
|
158
|
+
table = Document(output).tables[0]
|
|
159
|
+
assert table.cell(2, 0).text == "泵"
|
|
160
|
+
assert table.cell(2, 0).paragraphs[0].runs[0].bold
|
|
161
|
+
assert 'w:fill="AABBCC"' in table.cell(2, 0)._tc.xml
|
|
162
|
+
assert table.cell(3, 1).text == ""
|
|
163
|
+
assert_parts_preserved(source, output)
|
|
164
|
+
merged = tmp_path / "merged-rows.docx"
|
|
165
|
+
insert_docx_rows(source, merged, revision=rev(source), table=1, before=2, template_row=1, rows=[["合并", 3]])
|
|
166
|
+
assert Document(merged).tables[0].cell(1, 0).text == Document(merged).tables[0].cell(1, 1).text == "合并"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize("rows", [[["too", "short"]], [[{}, "unit", 1]], [["a", "b", 1, "extra"]], []])
|
|
170
|
+
def test_invalid_template_rows_do_not_create_output(source, tmp_path, rows):
|
|
171
|
+
with pytest.raises((ValueError, TypeError)):
|
|
172
|
+
insert_docx_rows(source, tmp_path / "bad.docx", revision=rev(source), table=1, before=3, template_row=2, rows=rows)
|
|
173
|
+
assert not (tmp_path / "bad.docx").exists()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_vertical_merge_clone_and_section_delete_are_rejected(tmp_path):
|
|
177
|
+
source = tmp_path / "sections.docx"
|
|
178
|
+
doc = Document()
|
|
179
|
+
table = doc.add_table(rows=2, cols=2)
|
|
180
|
+
table.cell(0, 0).merge(table.cell(1, 0)).text = "group"
|
|
181
|
+
doc.add_section()
|
|
182
|
+
doc.save(source)
|
|
183
|
+
with pytest.raises(ValueError, match="vertical merge"):
|
|
184
|
+
insert_docx_rows(source, tmp_path / "bad.docx", revision=rev(source), table=1, before=3, template_row=1, rows=[["a", "b"]])
|
|
185
|
+
with pytest.raises(ValueError, match="section properties"):
|
|
186
|
+
delete_docx_blocks(source, tmp_path / "bad.docx", revision=rev(source), first=2, last=2)
|
|
187
|
+
assert not (tmp_path / "bad.docx").exists()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_repeated_text_and_unknown_batch_fields_fail_without_writing(source, tmp_path):
|
|
191
|
+
for operations in [
|
|
192
|
+
[{"op": "replace_text", "target": {"block": 2}, "search": "missing", "replace": "new"}],
|
|
193
|
+
[{"op": "delete_blocks", "from": 4, "to": 5, "ignore": True}],
|
|
194
|
+
]:
|
|
195
|
+
with pytest.raises(ValueError):
|
|
196
|
+
edit_docx_structure(source, tmp_path / "bad.docx", revision=rev(source), operations=operations)
|
|
197
|
+
assert not (tmp_path / "bad.docx").exists()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_simple_cell_set_uses_revision_and_preserves_style(source, tmp_path):
|
|
201
|
+
output = tmp_path / "cell.docx"
|
|
202
|
+
set_docx_cell(source, output, revision=rev(source), table=1, row=2, column=1, text="新值")
|
|
203
|
+
assert Document(output).tables[0].cell(1, 0).text == "新值"
|
|
204
|
+
assert Document(output).tables[0].cell(1, 0).paragraphs[0].runs[0].bold
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|