licos-dev-sdk 0.4.1__tar.gz → 0.4.3__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 (47) hide show
  1. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/.gitignore +4 -0
  2. licos_dev_sdk-0.4.3/DOCX.md +73 -0
  3. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/PKG-INFO +1 -1
  4. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/pyproject.toml +1 -1
  5. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/__init__.py +10 -0
  6. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document_docx_edit.py +30 -40
  7. licos_dev_sdk-0.4.3/src/licos_dev_sdk/document_docx_package.py +57 -0
  8. licos_dev_sdk-0.4.3/src/licos_dev_sdk/document_docx_preservation.py +27 -0
  9. licos_dev_sdk-0.4.3/src/licos_dev_sdk/document_docx_structure.py +232 -0
  10. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document_docx_table.py +1 -0
  11. licos_dev_sdk-0.4.3/src/licos_dev_sdk/document_docx_table_actions.py +46 -0
  12. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document_read.py +6 -1
  13. licos_dev_sdk-0.4.3/tests/test_docx_structure.py +288 -0
  14. licos_dev_sdk-0.4.3/tests/test_html_docx_features.py +46 -0
  15. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/_utils.py +0 -0
  16. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/archive.py +0 -0
  17. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/chart.py +0 -0
  18. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/data.py +0 -0
  19. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/diagram.py +0 -0
  20. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document.py +0 -0
  21. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document_common.py +0 -0
  22. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document_docx.py +0 -0
  23. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document_docx_layout.py +0 -0
  24. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/document_table_export.py +0 -0
  25. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/image.py +0 -0
  26. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/model.py +0 -0
  27. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/observability.py +0 -0
  28. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  29. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
  30. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/presentation.py +0 -0
  31. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/presentation_canvas.py +0 -0
  32. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/presentation_inspect.py +0 -0
  33. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/spreadsheet.py +0 -0
  34. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
  35. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
  36. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  37. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/src/licos_dev_sdk/web.py +0 -0
  38. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_document_read.py +0 -0
  39. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_document_spreadsheet.py +0 -0
  40. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_document_table_export.py +0 -0
  41. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_docx_table_edit.py +0 -0
  42. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_model.py +0 -0
  43. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_observability.py +0 -0
  44. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_output_paths.py +0 -0
  45. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_ppt_master_runtime.py +0 -0
  46. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/tests/test_presentation.py +0 -0
  47. {licos_dev_sdk-0.4.1 → licos_dev_sdk-0.4.3}/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,73 @@
1
+ # Word SDK / CLI 0.4.3
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
+ Use `licos-dev --version` to query the installed document CLI version.
9
+
10
+ | CLI command | SDK function | Input |
11
+ | --- | --- | --- |
12
+ | `docx-structure` | `read_docx_structure` | File, blocks/paragraphs/tables, page |
13
+ | `docx-target` | `read_docx_target` | File, exact target, text cursor |
14
+ | `docx-replace` | `replace_docx_text` | Source/output, revision, target, search/replacement |
15
+ | `docx-delete-blocks` | `delete_docx_blocks` | Source/output, revision, inclusive block range |
16
+ | `docx-edit-structure` | `edit_docx_structure` | Source/output, revision, small operations array |
17
+ | `docx-table-set` | `set_docx_cell` | Source/output, revision, table/row/column, text |
18
+ | `docx-table-insert-rows` | `insert_docx_rows` | Source/output, revision, table, insertion point, template row, rows |
19
+ | `docx-verify` | `verify_docx` | File, optional positional assertions and original document |
20
+
21
+ ## Contract
22
+
23
+ - `target` has exactly one shape: `{"block":1}`, `{"paragraph":1}`, or
24
+ `{"table":1,"row":1,"column":1}`. Indices start at 1. Paragraph indices include
25
+ empty paragraphs and paragraphs inside tables; a body table occupies one block.
26
+ A merged cell must use its anchor coordinate. Existing `docx-read.paragraph`
27
+ is a legacy display ordinal; use its new `target` for editing.
28
+ - `revision` is the SHA256 of the source `word/document.xml` bytes. Reads and edits
29
+ return it. Every new editing entry point requires the source revision; the
30
+ next batch uses the output and its returned revision.
31
+ - Batch structure operations contain only `replace_text` with `target/search/replace`
32
+ or `delete_blocks` with `from/to`. All targets refer to the input snapshot.
33
+ Invalid, missing or removed targets and ambiguous matches fail before publishing output.
34
+ - `docx-replace` expects one exact match within one paragraph in the selected
35
+ target. It supports text split across runs and preserves surrounding formatting.
36
+ `--replace-file` carries long replacement text; simple replacements need no JSON.
37
+ - `docx-table-insert-rows --input rows.json` accepts scalar row arrays such as
38
+ `[["pump","set",2],["valve",null,3]]`. Row count is derived; each merged physical
39
+ cell is filled once. Row/cell/paragraph/run styles come from `--template-row`.
40
+ Templates that intersect vertical merges are rejected explicitly.
41
+ Choose exactly one of `--before N` (`before=N`) or `--append` (`append=True`).
42
+ Explicit append uses the current revision's row count. Table structure items
43
+ include `table`, `row_count`, `column_count` and a usable body block target.
44
+ - New editing commands default to refusing an existing output, including the
45
+ source itself. `--overwrite` explicitly allows replacement. Body edits are
46
+ atomic and keep all other ZIP part payloads unchanged. Existing `docx-edit`
47
+ retains its existing overwrite behavior and additionally supports `template_row`.
48
+ - Structure previews set `content_truncated`; `docx-target` retrieves exact text
49
+ without stripping whitespace and provides `next_text_offset` until `has_more=false`.
50
+ No partial JSON repair or automatic argument completion is performed.
51
+ - Verification without assertions does not certify content: `content_verified=false`.
52
+ Positional assertions use `equals`, `contains` or `absent`. A mismatch produces
53
+ `verified=false` and CLI exit code 2. Layout and page breaks require visual review.
54
+ One CLI assertion can use a target with `--text` or `--text-file` and optional
55
+ `--mode`; use `--input` for multiple assertions, never both input forms.
56
+ - `verify_docx(..., original=...)` / `docx-verify --original` compares every ZIP
57
+ part payload except `word/document.xml`, plus ordered section settings in the
58
+ body. It reports changed/added/missing parts and `preservation_verified`; a
59
+ failed comparison also sets `verified=false` / exit 2. The original must be a
60
+ separate unmodified file. This does not certify all body run formatting or
61
+ rendered layout, and does not substitute for positional content assertions.
62
+
63
+ ## Verification
64
+
65
+ Run Python tests serially with the current source on `PYTHONPATH`:
66
+
67
+ ```text
68
+ python -m pytest tools/licos-dev-sdk/tests/test_docx_structure.py tools/licos-dev-cli/tests/test_docx_commands.py
69
+ ```
70
+
71
+ Runtime acceptance must exercise skill discovery, file input, CLI execution and
72
+ independent DOCX verification through both AgentRuntime and CodexRuntime. The CLI
73
+ 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.3
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.3"
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,27 @@
1
+ """Compare untouched DOCX parts and section settings against an original file."""
2
+
3
+ from docx.oxml.ns import qn
4
+ from lxml import etree
5
+
6
+ from .document_docx_package import DOCUMENT_XML, DocxPackage
7
+
8
+
9
+ def compare_docx_preservation(current: DocxPackage, original: DocxPackage) -> dict:
10
+ if current.path.samefile(original.path):
11
+ raise ValueError("original must be a separate, unmodified document")
12
+ before = {info.filename: data for info, data in original.parts if info.filename != DOCUMENT_XML}
13
+ after = {info.filename: data for info, data in current.parts if info.filename != DOCUMENT_XML}
14
+ missing = sorted(before.keys() - after.keys())
15
+ added = sorted(after.keys() - before.keys())
16
+ changed = sorted(name for name in before.keys() & after.keys() if before[name] != after[name])
17
+
18
+ def sections(package):
19
+ return [etree.tostring(node, method="c14n", exclusive=True)
20
+ for node in package.root.iter(qn("w:sectPr"))]
21
+
22
+ sections_unchanged = sections(current) == sections(original)
23
+ return {"original": str(original.path), "compared_parts": len(before),
24
+ "excluded_parts": [DOCUMENT_XML], "missing_parts": missing,
25
+ "added_parts": added, "changed_parts": changed,
26
+ "section_properties_unchanged": sections_unchanged,
27
+ "verified": not (missing or added or changed) and sections_unchanged}
@@ -0,0 +1,232 @@
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_preservation import compare_docx_preservation
14
+ from .document_docx_table import parse_table
15
+
16
+ TEXT_TAGS = {qn("w:t"): None, qn("w:tab"): "\t", qn("w:br"): "\n", qn("w:cr"): "\n"}
17
+
18
+
19
+ def _index(value: int, name: str, minimum: int = 1) -> int:
20
+ if type(value) is not int or value < minimum:
21
+ raise ValueError(f"{name} must be an integer >= {minimum}")
22
+ return value
23
+
24
+
25
+ def _paragraph_text(paragraph: Any) -> str:
26
+ return "".join((node.text or "") if node.tag == qn("w:t") else TEXT_TAGS[node.tag]
27
+ for node in paragraph.iter() if node.tag in TEXT_TAGS)
28
+
29
+
30
+ def _text(element: Any) -> str:
31
+ return "\n".join(_paragraph_text(p) for p in element.iter(qn("w:p")))
32
+
33
+
34
+ class _Structure:
35
+ def __init__(self, root: Any):
36
+ self.body = root.find(qn("w:body"))
37
+ if self.body is None:
38
+ raise ValueError("DOCX body is missing")
39
+ self.blocks = [child for child in self.body if child.tag != qn("w:sectPr")]
40
+ self.paragraphs = list(self.body.iter(qn("w:p")))
41
+ self.tables = list(self.body.iterchildren(qn("w:tbl")))
42
+
43
+ def target(self, target: dict[str, int]) -> Any:
44
+ if not isinstance(target, dict):
45
+ raise TypeError("target must be an object")
46
+ keys = set(target)
47
+ if keys == {"block"} or keys == {"paragraph"}:
48
+ kind = next(iter(keys))
49
+ collection = self.blocks if kind == "block" else self.paragraphs
50
+ number = _index(target[kind], kind)
51
+ if number > len(collection):
52
+ raise ValueError(f"{kind} {number} does not exist")
53
+ return collection[number - 1]
54
+ if keys == {"table", "row", "column"}:
55
+ table, row, column = (_index(target[key], key) for key in ("table", "row", "column"))
56
+ if table > len(self.tables):
57
+ raise ValueError(f"table {table} does not exist")
58
+ cell = parse_table(self.tables[table - 1], include_nested=False).cell_at(row, column)
59
+ if cell is None:
60
+ raise ValueError(f"cell {target} does not exist")
61
+ if (cell.row, cell.column) != (row, column):
62
+ raise ValueError(f"use the merged cell anchor: table={table}, row={cell.row}, column={cell.column}")
63
+ return cell.element
64
+ raise ValueError("target must contain only block, only paragraph, or table+row+column")
65
+
66
+
67
+ def read_docx_structure(source: str | Path, *, scope: str = "blocks", offset: int = 1,
68
+ limit: int = 20, max_chars: int = 200) -> dict:
69
+ """Read stable one-based targets; paragraph numbering includes table paragraphs."""
70
+ _index(offset, "offset")
71
+ _index(limit, "limit")
72
+ _index(max_chars, "max_chars")
73
+ if scope not in {"blocks", "paragraphs", "tables"}:
74
+ raise ValueError("scope must be blocks, paragraphs or tables")
75
+ package = DocxPackage(source)
76
+ structure = _Structure(package.root)
77
+ elements = {"blocks": structure.blocks, "paragraphs": structure.paragraphs, "tables": structure.tables}[scope]
78
+ items = []
79
+ for number, element in enumerate(elements[offset - 1:offset - 1 + limit], offset):
80
+ text = _text(element)
81
+ item = {"target": {"paragraph" if scope == "paragraphs" else "block":
82
+ number if scope != "tables" else structure.blocks.index(element) + 1},
83
+ "type": etree.QName(element).localname,
84
+ "text": text[:max_chars], "content_truncated": len(text) > max_chars}
85
+ if element.tag == qn("w:tbl"):
86
+ model = parse_table(element, include_nested=False)
87
+ item.update(table=structure.tables.index(element) + 1, row_count=len(model.rows),
88
+ column_count=model.column_count)
89
+ items.append(item)
90
+ next_offset = offset + len(items)
91
+ return {"path": str(package.path), "revision": package.revision, "scope": scope,
92
+ "coordinate_systems": {"block": "top-level body blocks; a table is one block",
93
+ "paragraph": "all body paragraphs, including paragraphs in tables"},
94
+ "block_count": len(structure.blocks), "paragraph_count": len(structure.paragraphs),
95
+ "table_count": len(structure.tables), "items": items, "has_more": next_offset <= len(elements),
96
+ "next_offset": next_offset if next_offset <= len(elements) else None}
97
+
98
+
99
+ def read_docx_target(source: str | Path, target: dict[str, int], *, text_offset: int = 0,
100
+ max_chars: int = 8000) -> dict:
101
+ """Read a target's text with an explicit continuation cursor, without stripping whitespace."""
102
+ _index(text_offset, "text_offset", 0)
103
+ _index(max_chars, "max_chars")
104
+ package = DocxPackage(source)
105
+ text = _text(_Structure(package.root).target(target))
106
+ part = text[text_offset:text_offset + max_chars]
107
+ end = text_offset + len(part)
108
+ return {"path": str(package.path), "revision": package.revision, "target": target,
109
+ "text": part, "text_offset": text_offset, "total_characters": len(text),
110
+ "has_more": end < len(text), "next_text_offset": end if end < len(text) else None}
111
+
112
+
113
+ def _replacement_nodes(value: str) -> list:
114
+ nodes = []
115
+ for part in re.split("(\n|\t)", value):
116
+ if not part:
117
+ continue
118
+ node = etree.Element(qn("w:br" if part == "\n" else "w:tab" if part == "\t" else "w:t"))
119
+ if part not in {"\n", "\t"}:
120
+ node.text = part
121
+ node.set(qn("xml:space"), "preserve")
122
+ nodes.append(node)
123
+ return nodes
124
+
125
+
126
+ def _replace(target: Any, search: str, replacement: str) -> None:
127
+ if not isinstance(search, str) or not search or not isinstance(replacement, str):
128
+ raise ValueError("search must be a non-empty string and replace must be a string")
129
+ matches = []
130
+ for paragraph in target.iter(qn("w:p")):
131
+ text = _paragraph_text(paragraph)
132
+ start = text.find(search)
133
+ while start >= 0:
134
+ matches.append((paragraph, start))
135
+ start = text.find(search, start + 1)
136
+ if len(matches) != 1:
137
+ raise ValueError(f"search must match exactly once within one target paragraph; found {len(matches)}; "
138
+ f"target text={_text(target)[:200]!r}")
139
+ paragraph, start = matches[0]
140
+ end, offset, inserted = start + len(search), 0, False
141
+ for node in [n for n in paragraph.iter() if n.tag in TEXT_TAGS]:
142
+ value = (node.text or "") if node.tag == qn("w:t") else TEXT_TAGS[node.tag]
143
+ next_offset = offset + len(value)
144
+ if next_offset > start and offset < end:
145
+ new_value = value[:max(0, start - offset)] + ("" if inserted else replacement) + value[min(len(value), end - offset):]
146
+ parent, index = node.getparent(), node.getparent().index(node)
147
+ parent.remove(node)
148
+ for added in _replacement_nodes(new_value):
149
+ parent.insert(index, added)
150
+ index += 1
151
+ inserted = True
152
+ offset = next_offset
153
+
154
+
155
+ def edit_docx_structure(source: str | Path, output: str | Path, *, revision: str,
156
+ operations: list[dict], overwrite: bool = False) -> dict:
157
+ """Apply up to 200 edits against one read snapshot, atomically preserving other parts."""
158
+ if not isinstance(operations, list) or not 1 <= len(operations) <= 200:
159
+ raise ValueError("operations must be an array with 1-200 entries")
160
+ package = DocxPackage(source)
161
+ package.require_revision(revision)
162
+ structure = _Structure(package.root)
163
+ resolved = []
164
+ for index, operation in enumerate(operations, 1):
165
+ if not isinstance(operation, dict):
166
+ raise TypeError(f"operation {index} must be an object")
167
+ if operation.get("op") == "replace_text" and set(operation) == {"op", "target", "search", "replace"}:
168
+ resolved.append((operation, [structure.target(operation["target"])]))
169
+ elif operation.get("op") == "delete_blocks" and set(operation) == {"op", "from", "to"}:
170
+ first, last = _index(operation["from"], "from"), _index(operation["to"], "to")
171
+ if first > last or last > len(structure.blocks):
172
+ raise ValueError(f"operation {index}: invalid block range")
173
+ resolved.append((operation, structure.blocks[first - 1:last]))
174
+ else:
175
+ raise ValueError(f"operation {index}: expected replace_text(op,target,search,replace) or delete_blocks(op,from,to)")
176
+ for index, (operation, elements) in enumerate(resolved, 1):
177
+ try:
178
+ for element in elements:
179
+ if structure.body not in element.iterancestors():
180
+ raise ValueError("target was removed by an earlier operation")
181
+ if operation["op"] == "replace_text":
182
+ _replace(element, operation["search"], operation["replace"])
183
+ else:
184
+ if next(element.iter(qn("w:sectPr")), None) is not None:
185
+ raise ValueError("block contains section properties; deleting it would change section layout")
186
+ structure.body.remove(element)
187
+ except ValueError as exc:
188
+ raise ValueError(f"operation {index} ({operation['op']}), target={operation.get('target')}: {exc}") from exc
189
+ return {**package.save(output, overwrite=overwrite), "applied_operations": len(operations)}
190
+
191
+
192
+ def replace_docx_text(source: str | Path, output: str | Path, *, revision: str,
193
+ target: dict[str, int], search: str, replacement: str, overwrite: bool = False) -> dict:
194
+ return edit_docx_structure(source, output, revision=revision, overwrite=overwrite,
195
+ operations=[{"op": "replace_text", "target": target, "search": search, "replace": replacement}])
196
+
197
+
198
+ def delete_docx_blocks(source: str | Path, output: str | Path, *, revision: str,
199
+ first: int, last: int, overwrite: bool = False) -> dict:
200
+ return edit_docx_structure(source, output, revision=revision, overwrite=overwrite,
201
+ operations=[{"op": "delete_blocks", "from": first, "to": last}])
202
+
203
+
204
+ def verify_docx(source: str | Path, *, expectations: list[dict] | None = None,
205
+ original: str | Path | None = None) -> dict:
206
+ """Check package structure and exact positions; absent assertions do not certify content."""
207
+ package = DocxPackage(source)
208
+ structure = _Structure(package.root)
209
+ for table in structure.tables:
210
+ parse_table(table)
211
+ expectations = [] if expectations is None else expectations
212
+ if not isinstance(expectations, list):
213
+ raise TypeError("expectations must be an array")
214
+ mismatches = []
215
+ for index, expectation in enumerate(expectations, 1):
216
+ if not isinstance(expectation, dict) or not {"target", "text"} <= set(expectation) or set(expectation) - {"target", "text", "mode"}:
217
+ raise ValueError(f"expectation {index} requires target,text and optional mode")
218
+ expected, mode = expectation["text"], expectation.get("mode", "equals")
219
+ if not isinstance(expected, str) or mode not in {"equals", "contains", "absent"}:
220
+ raise ValueError(f"expectation {index}: text must be a string; mode must be equals, contains or absent")
221
+ actual = _text(structure.target(expectation["target"]))
222
+ passed = actual == expected if mode == "equals" else expected in actual if mode == "contains" else expected not in actual
223
+ if not passed:
224
+ mismatches.append({"index": index, "target": expectation["target"], "mode": mode, "expected": expected,
225
+ "actual": actual[:1000], "actual_truncated": len(actual) > 1000})
226
+ preservation = compare_docx_preservation(package, DocxPackage(original)) if original is not None else None
227
+ return {"path": str(package.path), "revision": package.revision, "structure_verified": True,
228
+ "content_verified": bool(expectations) and not mismatches,
229
+ "preservation_verified": preservation is not None and preservation["verified"],
230
+ "preservation": preservation,
231
+ "verified": not mismatches and (preservation is None or preservation["verified"]),
232
+ "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,46 @@
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
+ from .document_docx_table import parse_table
9
+
10
+
11
+ def set_docx_cell(source: str | Path, output: str | Path, *, revision: str,
12
+ table: int, row: int, column: int, text: str, overwrite: bool = False) -> dict:
13
+ if not isinstance(text, str):
14
+ raise TypeError("text must be a string")
15
+ package = DocxPackage(source)
16
+ package.require_revision(revision)
17
+ _Structure(package.root).target({"table": table, "row": row, "column": column})
18
+ _apply_operation(package.root, {"op": "set_cell", "table": table, "row": row, "column": column, "text": text})
19
+ return package.save(output, overwrite=overwrite)
20
+
21
+
22
+ def insert_docx_rows(source: str | Path, output: str | Path, *, revision: str,
23
+ table: int, rows: list[list], template_row: int,
24
+ before: int | None = None, append: bool = False,
25
+ overwrite: bool = False) -> dict:
26
+ """Clone styled rows at an explicit position or append to the current table."""
27
+ if type(append) is not bool:
28
+ raise TypeError("append must be a boolean")
29
+ if (before is not None) == append:
30
+ raise ValueError("provide exactly one of before and append=True")
31
+ for name, value in (("table", table), ("template_row", template_row)):
32
+ _index(value, name)
33
+ if before is not None:
34
+ _index(before, "before")
35
+ if not isinstance(rows, list) or not 1 <= len(rows) <= 200:
36
+ raise ValueError("rows must contain 1-200 row arrays")
37
+ package = DocxPackage(source)
38
+ package.require_revision(revision)
39
+ if append:
40
+ tables = _Structure(package.root).tables
41
+ if table > len(tables):
42
+ raise ValueError(f"table {table} does not exist")
43
+ before = len(parse_table(tables[table - 1], include_nested=False).rows) + 1
44
+ _apply_operation(package.root, {"op": "insert_rows", "table": table, "before": before,
45
+ "template_row": template_row, "count": len(rows), "rows": rows})
46
+ 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,288 @@
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
205
+
206
+
207
+ def test_table_discovery_returns_body_target_and_current_dimensions(source, tmp_path):
208
+ doc = Document(source)
209
+ doc.add_table(rows=3, cols=2)
210
+ doc.save(source)
211
+ first = read_docx_structure(source, scope="tables", limit=1)
212
+ item = first["items"][0]
213
+ assert (item["target"], item["table"], item["row_count"], item["column_count"]) == ({"block": 3}, 1, 2, 3)
214
+ assert read_docx_structure(source)["items"][2] == item
215
+ second = read_docx_structure(source, scope="tables", offset=first["next_offset"], limit=1)
216
+ item = second["items"][0]
217
+ assert (item["target"], item["table"], item["row_count"], item["column_count"]) == ({"block": 6}, 2, 3, 2)
218
+ assert not second["has_more"]
219
+ assert read_docx_target(source, item["target"])["text"] == item["text"]
220
+
221
+
222
+ def test_explicit_append_uses_current_revision_and_preserves_merged_template(source, tmp_path):
223
+ first, second = tmp_path / "first.docx", tmp_path / "second.docx"
224
+ insert_docx_rows(source, first, revision=rev(source), table=1, append=True, template_row=2, rows=[["泵", "台", 2]])
225
+ insert_docx_rows(first, second, revision=rev(first), table=1, append=True, template_row=1, rows=[["合并尾行", 9]])
226
+ table = Document(second).tables[0]
227
+ assert len(table.rows) == 4
228
+ assert table.cell(2, 0).text == "泵" and table.cell(2, 0).paragraphs[0].runs[0].bold
229
+ assert table.cell(3, 0).text == table.cell(3, 1).text == "合并尾行"
230
+ assert_parts_preserved(source, second)
231
+ with pytest.raises(ValueError, match="revision"):
232
+ insert_docx_rows(first, tmp_path / "stale.docx", revision=rev(source), table=1, append=True, template_row=2, rows=[["a", "b", 1]])
233
+ assert not (tmp_path / "stale.docx").exists()
234
+
235
+
236
+ @pytest.mark.parametrize("position", [{}, {"before": 3, "append": True}, {"before": True}, {"append": 1}])
237
+ def test_insert_requires_one_explicit_position(source, tmp_path, position):
238
+ output = tmp_path / "bad.docx"
239
+ with pytest.raises((ValueError, TypeError)):
240
+ insert_docx_rows(source, output, revision=rev(source), table=1, template_row=2, rows=[["a", "b", 1]], **position)
241
+ assert not output.exists()
242
+
243
+
244
+ def test_original_comparison_certifies_parts_and_sections_but_requires_content_assertions(source, tmp_path):
245
+ output = tmp_path / "edited.docx"
246
+ replace_docx_text(source, output, revision=rev(source), target={"block": 2}, search="123456", replacement="新账号")
247
+ report = verify_docx(output, original=source)
248
+ assert report["verified"] and report["preservation_verified"]
249
+ assert report["preservation"]["section_properties_unchanged"]
250
+ assert not report["content_verified"]
251
+ assert not verify_docx(output, original=source, expectations=[{"target": {"block": 2}, "text": "错误"}])["verified"]
252
+ with pytest.raises(ValueError, match="separate"):
253
+ verify_docx(output, original=output)
254
+
255
+
256
+ @pytest.mark.parametrize("mutation,part,field", [
257
+ ("change", "word/header1.xml", "changed_parts"),
258
+ ("change", "word/styles.xml", "changed_parts"),
259
+ ("remove", "word/header1.xml", "missing_parts"),
260
+ ("add", "word/media/extra.png", "added_parts"),
261
+ ])
262
+ def test_original_comparison_detects_changes_outside_body(source, tmp_path, mutation, part, field):
263
+ output = tmp_path / "changed.docx"
264
+ with ZipFile(source) as before, ZipFile(output, "w") as after:
265
+ for info in before.infolist():
266
+ if mutation == "remove" and info.filename == part:
267
+ continue
268
+ data = before.read(info.filename)
269
+ after.writestr(info, data + b" " if mutation == "change" and info.filename == part else data)
270
+ if mutation == "add":
271
+ after.writestr(part, b"image")
272
+ report = verify_docx(output, original=source)
273
+ assert not report["verified"] and not report["preservation_verified"]
274
+ assert report["preservation"][field] == [part]
275
+
276
+
277
+ def test_original_comparison_detects_section_layout_changes(source, tmp_path):
278
+ from licos_dev_sdk.document_docx_package import DocxPackage
279
+
280
+ package = DocxPackage(source)
281
+ section = next(package.root.iter(qn("w:sectPr")))
282
+ section.find(qn("w:pgMar")).set(qn("w:left"), "999")
283
+ output = tmp_path / "layout.docx"
284
+ package.save(output)
285
+ report = verify_docx(output, original=source)
286
+ assert not report["verified"]
287
+ assert not report["preservation"]["section_properties_unchanged"]
288
+ assert report["preservation"]["changed_parts"] == []
@@ -0,0 +1,46 @@
1
+ """Exercise the public HTML-to-Word API with actual document parts and images."""
2
+
3
+ import base64
4
+ from zipfile import ZipFile
5
+
6
+ from docx import Document
7
+ from docx.oxml.ns import qn
8
+ from PIL import Image
9
+
10
+ from licos_dev_sdk import create_docx
11
+
12
+
13
+ def test_html_generates_native_toc_merged_table_and_embedded_images(tmp_path):
14
+ picture = tmp_path / "figure.png"
15
+ Image.new("RGB", (80, 40), "#145DA0").save(picture)
16
+ encoded = base64.b64encode(picture.read_bytes()).decode("ascii")
17
+ html = f'''<html><head><style>
18
+ th {{ background-color: #145DA0; color: #ffffff; }}
19
+ td {{ border: 1px solid #333333; }}
20
+ </style></head><body>
21
+ <h1>测试报告</h1><h2>设备统计</h2><h3>设备清单</h3>
22
+ <table><tr><th colspan="2">设备</th><th>数量</th></tr>
23
+ <tr><td rowspan="2">泵组</td><td>离心泵</td><td>2</td></tr>
24
+ <tr><td>阀门</td><td>3</td></tr></table>
25
+ <p><img src="figure.png" width="160" alt="本地图片"></p>
26
+ <p><img src="data:image/png;base64,{encoded}" width="160" alt="内嵌图片"></p>
27
+ </body></html>'''
28
+ output = create_docx(html, "report", content_type="html", output_dir=str(tmp_path),
29
+ asset_base_path=str(tmp_path), include_toc=True, header="内部资料", footer="测试页脚")
30
+ document = Document(output)
31
+ assert [p.text for p in document.paragraphs if p.style.name.startswith("Heading")] == ["测试报告", "设备统计", "设备清单"]
32
+ fields = [n.text for n in document.element.iter(qn("w:instrText"))]
33
+ assert any('TOC \\o "1-3"' in text for text in fields)
34
+ assert document.settings.element.find(qn("w:updateFields")).get(qn("w:val")) == "true"
35
+ table = document.tables[0]
36
+ assert len(table.rows) == 3 and len(table.columns) == 3
37
+ assert table.cell(0, 0)._tc is table.cell(0, 1)._tc
38
+ assert table.cell(1, 0)._tc is table.cell(2, 0)._tc
39
+ assert table.cell(2, 1).text == "阀门" and table.cell(2, 2).text == "3"
40
+ assert 'w:fill="145DA0"' in table.cell(0, 0)._tc.xml
41
+ assert len(document.inline_shapes) == 2
42
+ assert "内部资料" in document.sections[0].header.paragraphs[0].text
43
+ assert "测试页脚" in document.sections[0].footer.paragraphs[0].text
44
+ with ZipFile(output) as archive:
45
+ images = [archive.read(name) for name in archive.namelist() if name.startswith("word/media/")]
46
+ assert picture.read_bytes() in images