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.
Files changed (45) hide show
  1. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/.gitignore +4 -0
  2. licos_dev_sdk-0.4.2/DOCX.md +60 -0
  3. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/PKG-INFO +1 -1
  4. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/pyproject.toml +1 -1
  5. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/__init__.py +10 -0
  6. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx_edit.py +30 -40
  7. licos_dev_sdk-0.4.2/src/licos_dev_sdk/document_docx_package.py +57 -0
  8. licos_dev_sdk-0.4.2/src/licos_dev_sdk/document_docx_structure.py +219 -0
  9. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx_table.py +1 -0
  10. licos_dev_sdk-0.4.2/src/licos_dev_sdk/document_docx_table_actions.py +33 -0
  11. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_read.py +6 -1
  12. licos_dev_sdk-0.4.2/tests/test_docx_structure.py +204 -0
  13. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/_utils.py +0 -0
  14. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/archive.py +0 -0
  15. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/chart.py +0 -0
  16. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/data.py +0 -0
  17. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/diagram.py +0 -0
  18. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document.py +0 -0
  19. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_common.py +0 -0
  20. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx.py +0 -0
  21. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_docx_layout.py +0 -0
  22. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/document_table_export.py +0 -0
  23. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/image.py +0 -0
  24. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/model.py +0 -0
  25. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/observability.py +0 -0
  26. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  27. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
  28. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/presentation.py +0 -0
  29. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/presentation_canvas.py +0 -0
  30. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/presentation_inspect.py +0 -0
  31. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet.py +0 -0
  32. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
  33. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
  34. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  35. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/src/licos_dev_sdk/web.py +0 -0
  36. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_document_read.py +0 -0
  37. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_document_spreadsheet.py +0 -0
  38. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_document_table_export.py +0 -0
  39. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_docx_table_edit.py +0 -0
  40. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_model.py +0 -0
  41. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_observability.py +0 -0
  42. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_output_paths.py +0 -0
  43. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_ppt_master_runtime.py +0 -0
  44. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.2}/tests/test_presentation.py +0 -0
  45. {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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.4.1
3
+ Version: 0.4.2
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.1"
7
+ version = "0.4.2"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -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
- 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)
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
- 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)
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
- row_element = _blank_row(model.column_count, row_values)
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