licos-dev-sdk 0.4.2__tar.gz → 0.4.4__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 (50) hide show
  1. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/DOCX.md +32 -5
  2. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/PKG-INFO +1 -1
  3. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/pyproject.toml +1 -1
  4. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/__init__.py +2 -1
  5. licos_dev_sdk-0.4.4/src/licos_dev_sdk/document_docx_expectations.py +30 -0
  6. licos_dev_sdk-0.4.4/src/licos_dev_sdk/document_docx_inspect.py +90 -0
  7. licos_dev_sdk-0.4.4/src/licos_dev_sdk/document_docx_preservation.py +27 -0
  8. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_docx_structure.py +22 -12
  9. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_docx_table_actions.py +16 -3
  10. licos_dev_sdk-0.4.4/tests/test_docx_inspect.py +113 -0
  11. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_docx_structure.py +84 -0
  12. licos_dev_sdk-0.4.4/tests/test_html_docx_features.py +46 -0
  13. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/.gitignore +0 -0
  14. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/_utils.py +0 -0
  15. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/archive.py +0 -0
  16. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/chart.py +0 -0
  17. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/data.py +0 -0
  18. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/diagram.py +0 -0
  19. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document.py +0 -0
  20. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_common.py +0 -0
  21. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_docx.py +0 -0
  22. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_docx_edit.py +0 -0
  23. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_docx_layout.py +0 -0
  24. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_docx_package.py +0 -0
  25. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_docx_table.py +0 -0
  26. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_read.py +0 -0
  27. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/document_table_export.py +0 -0
  28. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/image.py +0 -0
  29. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/model.py +0 -0
  30. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/observability.py +0 -0
  31. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  32. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/ppt_master_runtime.py +0 -0
  33. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/presentation.py +0 -0
  34. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/presentation_canvas.py +0 -0
  35. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/presentation_inspect.py +0 -0
  36. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/spreadsheet.py +0 -0
  37. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/spreadsheet_edit.py +0 -0
  38. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/spreadsheet_html.py +0 -0
  39. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  40. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/src/licos_dev_sdk/web.py +0 -0
  41. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_document_read.py +0 -0
  42. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_document_spreadsheet.py +0 -0
  43. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_document_table_export.py +0 -0
  44. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_docx_table_edit.py +0 -0
  45. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_model.py +0 -0
  46. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_observability.py +0 -0
  47. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_output_paths.py +0 -0
  48. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_ppt_master_runtime.py +0 -0
  49. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_presentation.py +0 -0
  50. {licos_dev_sdk-0.4.2 → licos_dev_sdk-0.4.4}/tests/test_spreadsheet_html_edit.py +0 -0
@@ -1,20 +1,23 @@
1
- # Word SDK / CLI 0.4.2
1
+ # Word SDK / CLI 0.4.4
2
2
 
3
3
  Cloud document execution stays in `licos-dev-sdk` and `licos-dev`. CLI commands
4
4
  only parse arguments and call the SDK; no additional Agent-native Word tools are
5
5
  registered. Existing `docx`, `docx-template`, `docx-read`, `docx-edit` commands and
6
6
  their Python APIs retain their invocation contracts.
7
7
 
8
+ Use `licos-dev --version` to query the installed document CLI version.
9
+
8
10
  | CLI command | SDK function | Input |
9
11
  | --- | --- | --- |
10
- | `docx-structure` | `read_docx_structure` | File, blocks/paragraphs, page |
12
+ | `docx-structure` | `read_docx_structure` | File, blocks/paragraphs/tables, page |
13
+ | `docx-inspect` | `inspect_docx` | File, all/fields/images/tables, offset/limit |
11
14
  | `docx-target` | `read_docx_target` | File, exact target, text cursor |
12
15
  | `docx-replace` | `replace_docx_text` | Source/output, revision, target, search/replacement |
13
16
  | `docx-delete-blocks` | `delete_docx_blocks` | Source/output, revision, inclusive block range |
14
17
  | `docx-edit-structure` | `edit_docx_structure` | Source/output, revision, small operations array |
15
18
  | `docx-table-set` | `set_docx_cell` | Source/output, revision, table/row/column, text |
16
19
  | `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 |
20
+ | `docx-verify` | `verify_docx` | File, optional positional assertions and original document |
18
21
 
19
22
  ## Contract
20
23
 
@@ -28,7 +31,7 @@ their Python APIs retain their invocation contracts.
28
31
  next batch uses the output and its returned revision.
29
32
  - Batch structure operations contain only `replace_text` with `target/search/replace`
30
33
  or `delete_blocks` with `from/to`. All targets refer to the input snapshot.
31
- Invalid, missing, duplicate or removed targets fail before publishing output.
34
+ Invalid, missing or removed targets and ambiguous matches fail before publishing output.
32
35
  - `docx-replace` expects one exact match within one paragraph in the selected
33
36
  target. It supports text split across runs and preserves surrounding formatting.
34
37
  `--replace-file` carries long replacement text; simple replacements need no JSON.
@@ -36,6 +39,9 @@ their Python APIs retain their invocation contracts.
36
39
  `[["pump","set",2],["valve",null,3]]`. Row count is derived; each merged physical
37
40
  cell is filled once. Row/cell/paragraph/run styles come from `--template-row`.
38
41
  Templates that intersect vertical merges are rejected explicitly.
42
+ Choose exactly one of `--before N` (`before=N`) or `--append` (`append=True`).
43
+ Explicit append uses the current revision's row count. Table structure items
44
+ include `table`, `row_count`, `column_count` and a usable body block target.
39
45
  - New editing commands default to refusing an existing output, including the
40
46
  source itself. `--overwrite` explicitly allows replacement. Body edits are
41
47
  atomic and keep all other ZIP part payloads unchanged. Existing `docx-edit`
@@ -43,12 +49,33 @@ their Python APIs retain their invocation contracts.
43
49
  - Structure previews set `content_truncated`; `docx-target` retrieves exact text
44
50
  without stripping whitespace and provides `next_text_offset` until `has_more=false`.
45
51
  No partial JSON repair or automatic argument completion is performed.
46
- - Verification without assertions checks structure only: `content_verified=false`.
52
+ - Verification without assertions does not certify content: `content_verified=false`.
47
53
  Positional assertions use `equals`, `contains` or `absent`. A mismatch produces
48
54
  `verified=false` and CLI exit code 2. Layout and page breaks require visual review.
55
+ One CLI assertion can use a target with `--text` or `--text-file` and optional
56
+ `--mode`; use `--input` for multiple assertions, never both input forms.
57
+ - `verify_docx(..., original=...)` / `docx-verify --original` compares every ZIP
58
+ part payload except `word/document.xml`, plus ordered section settings in the
59
+ body. It reports changed/added/missing parts and `preservation_verified`; a
60
+ failed comparison also sets `verified=false` / exit 2. The original must be a
61
+ separate unmodified file. This does not certify all body run formatting or
62
+ rendered layout, and does not substitute for positional content assertions.
49
63
 
50
64
  ## Verification
51
65
 
66
+ `docx-inspect` reports native field instructions (including split-run TOC fields),
67
+ field completeness, embedded/external/missing image references, and table dimensions
68
+ across document stories, including headers and footers. Each scope has its own
69
+ `next_offset`; counts cover the entire document. `update_fields_on_open` describes
70
+ the Word setting; neither it nor `toc_count` certifies rendered pagination.
71
+ Use `docx-structure` for editable body targets.
72
+
73
+ Batch verification entries require `target`, exactly one of `text` or `text_file`,
74
+ and optional `mode`. JSON `text_file` is a UTF-8 path relative to the JSON file's
75
+ directory. Single CLI `--text-file` is relative to cwd. SDK `base_dir` defaults to
76
+ cwd and can set the file resolution directory explicitly. Empty text is valid;
77
+ unknown keys, conflicting inputs and file errors identify the assertion and field.
78
+
52
79
  Run Python tests serially with the current source on `PYTHONPATH`:
53
80
 
54
81
  ```text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.4.2
3
+ Version: 0.4.4
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.2"
7
+ version = "0.4.4"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -30,6 +30,7 @@ def __getattr__(name: str):
30
30
  "create_docx_from_template": ("document", "create_docx_from_template"),
31
31
  "edit_docx": ("document", "edit_docx"),
32
32
  "read_docx_structure": ("document_docx_structure", "read_docx_structure"),
33
+ "inspect_docx": ("document_docx_inspect", "inspect_docx"),
33
34
  "read_docx_target": ("document_docx_structure", "read_docx_target"),
34
35
  "edit_docx_structure": ("document_docx_structure", "edit_docx_structure"),
35
36
  "replace_docx_text": ("document_docx_structure", "replace_docx_text"),
@@ -108,7 +109,7 @@ def __getattr__(name: str):
108
109
 
109
110
 
110
111
  __all__ = [
111
- "read_docx_structure", "read_docx_target", "edit_docx_structure",
112
+ "inspect_docx", "read_docx_structure", "read_docx_target", "edit_docx_structure",
112
113
  "replace_docx_text", "delete_docx_blocks", "verify_docx", "set_docx_cell", "insert_docx_rows",
113
114
  "create_json", "create_xml", "create_yaml",
114
115
  "create_zip", "create_tar_gz",
@@ -0,0 +1,30 @@
1
+ """The shared single/batch verification assertion contract."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def expected_text(expectation, index, base_dir):
7
+ prefix = f"expectation {index}"
8
+ if not isinstance(expectation, dict):
9
+ raise ValueError(f"{prefix} must be an object")
10
+ unknown = set(expectation) - {"target", "text", "text_file", "mode"}
11
+ if unknown:
12
+ raise ValueError(f"{prefix}: unknown fields {', '.join(sorted(unknown))}; allowed: target, text, text_file, mode")
13
+ if "target" not in expectation:
14
+ raise ValueError(f"{prefix}.target is required")
15
+ if ("text" in expectation) == ("text_file" in expectation):
16
+ raise ValueError(f"{prefix}: provide exactly one of text and text_file")
17
+ field = "text_file" if "text_file" in expectation else "text"
18
+ value = expectation[field]
19
+ if not isinstance(value, str) or (field == "text_file" and not value):
20
+ raise ValueError(f"{prefix}.{field} must be {'a non-empty' if field == 'text_file' else 'a'} string")
21
+ mode = expectation.get("mode", "equals")
22
+ if mode not in ("equals", "contains", "absent"):
23
+ raise ValueError(f"{prefix}.mode must be equals, contains or absent")
24
+ if field == "text_file":
25
+ path = Path(base_dir) / Path(value).expanduser()
26
+ try:
27
+ value = path.read_text(encoding="utf-8")
28
+ except (OSError, UnicodeError) as exc:
29
+ raise ValueError(f"{prefix}.text_file: {exc}") from exc
30
+ return value, mode
@@ -0,0 +1,90 @@
1
+ """Inspect native Word fields, image references and tables without modifying a package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+ import posixpath
7
+
8
+ from lxml import etree
9
+
10
+ from .document_docx_package import DocxPackage
11
+
12
+ W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
13
+ R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
14
+ A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
15
+ V = "{urn:schemas-microsoft-com:vml}"
16
+
17
+
18
+ def _xml(data):
19
+ return etree.fromstring(data, etree.XMLParser(resolve_entities=False, no_network=True))
20
+
21
+
22
+ def _fields(root, part):
23
+ fields, stack = [], []
24
+ for element in root.iter():
25
+ if element.tag == W + "fldSimple":
26
+ fields.append({"part": part, "instruction": element.get(W + "instr", ""), "complete": True})
27
+ elif element.tag == W + "fldChar":
28
+ kind = element.get(W + "fldCharType")
29
+ if kind == "begin":
30
+ field = {"part": part, "instruction": "", "complete": False}
31
+ fields.append(field)
32
+ stack.append((field, True))
33
+ elif kind == "separate" and stack:
34
+ stack[-1] = (stack[-1][0], False)
35
+ elif kind == "end" and stack:
36
+ stack.pop()[0]["complete"] = True
37
+ elif element.tag == W + "instrText" and stack and stack[-1][1]:
38
+ stack[-1][0]["instruction"] += element.text or ""
39
+ for field in fields:
40
+ instruction = field["instruction"].strip()
41
+ field["type"] = instruction.split(maxsplit=1)[0].upper() if instruction else ""
42
+ return fields
43
+
44
+
45
+ def inspect_docx(source: str | Path, *, scope: str = "all", offset: int = 1, limit: int = 20) -> dict:
46
+ """Return counts and paginated fields/images/tables. Field presence does not certify pagination."""
47
+ if scope not in {"all", "fields", "images", "tables"}:
48
+ raise ValueError("scope must be all, fields, images or tables")
49
+ if type(offset) is not int or offset < 1 or type(limit) is not int or not 1 <= limit <= 200:
50
+ raise ValueError("offset must be positive; limit must be 1-200")
51
+ package = DocxPackage(source)
52
+ parts = {info.filename: data for info, data in package.parts}
53
+ fields, images, tables = [], [], []
54
+ for part, data in parts.items():
55
+ if not part.startswith("word/") or not part.endswith(".xml"):
56
+ continue
57
+ root = package.root if part == "word/document.xml" else _xml(data)
58
+ fields.extend(_fields(root, part))
59
+ rel_path = str(PurePosixPath(part).parent / "_rels" / (PurePosixPath(part).name + ".rels"))
60
+ relationships = {rel.get("Id"): rel for rel in _xml(parts[rel_path])} if rel_path in parts else {}
61
+ for element in root.iter():
62
+ if element.tag == W + "tbl":
63
+ grid = element.find(W + "tblGrid")
64
+ tables.append({"part": part, "row_count": len(element.findall(W + "tr")),
65
+ "column_count": len(grid) if grid is not None else None})
66
+ if element.tag not in {A + "blip", V + "imagedata"}:
67
+ continue
68
+ rid = element.get(R + "embed") or element.get(R + "link") or element.get(R + "id")
69
+ rel = relationships.get(rid)
70
+ if rel is None:
71
+ images.append({"part": part, "relationship_id": rid, "status": "missing_relationship"})
72
+ continue
73
+ target = rel.get("Target", "")
74
+ external = rel.get("TargetMode") == "External"
75
+ target_part = None if external else posixpath.normpath(posixpath.join(posixpath.dirname(part), target)).lstrip("/")
76
+ images.append({"part": part, "relationship_id": rid, "target": target,
77
+ "external": external, "package_part": target_part,
78
+ "status": "external" if external else "embedded" if target_part in parts else "missing_part"})
79
+ settings = _xml(parts["word/settings.xml"]) if "word/settings.xml" in parts else None
80
+ update = settings.find(W + "updateFields") if settings is not None else None
81
+ report = {"path": str(package.path), "revision": package.revision,
82
+ "field_count": len(fields), "toc_count": sum(field["type"] == "TOC" for field in fields),
83
+ "image_count": len(images), "table_count": len(tables),
84
+ "update_fields_on_open": update is not None and update.get(W + "val", "true") not in {"false", "0", "off"},
85
+ "rendered_layout_verified": False}
86
+ for name, items in (("fields", fields), ("images", images), ("tables", tables)):
87
+ if scope in {"all", name}:
88
+ selected = items[offset - 1:offset - 1 + limit]
89
+ report[name] = {"items": selected, "next_offset": offset + len(selected) if offset - 1 + len(selected) < len(items) else None}
90
+ return report
@@ -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}
@@ -10,6 +10,7 @@ from docx.oxml.ns import qn
10
10
  from lxml import etree
11
11
 
12
12
  from .document_docx_package import DocxPackage
13
+ from .document_docx_preservation import compare_docx_preservation
13
14
  from .document_docx_table import parse_table
14
15
 
15
16
  TEXT_TAGS = {qn("w:t"): None, qn("w:tab"): "\t", qn("w:br"): "\n", qn("w:cr"): "\n"}
@@ -69,16 +70,23 @@ def read_docx_structure(source: str | Path, *, scope: str = "blocks", offset: in
69
70
  _index(offset, "offset")
70
71
  _index(limit, "limit")
71
72
  _index(max_chars, "max_chars")
72
- if scope not in {"blocks", "paragraphs"}:
73
- raise ValueError("scope must be blocks or paragraphs")
73
+ if scope not in {"blocks", "paragraphs", "tables"}:
74
+ raise ValueError("scope must be blocks, paragraphs or tables")
74
75
  package = DocxPackage(source)
75
76
  structure = _Structure(package.root)
76
- elements = structure.blocks if scope == "blocks" else structure.paragraphs
77
+ elements = {"blocks": structure.blocks, "paragraphs": structure.paragraphs, "tables": structure.tables}[scope]
77
78
  items = []
78
79
  for number, element in enumerate(elements[offset - 1:offset - 1 + limit], offset):
79
80
  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})
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)
82
90
  next_offset = offset + len(items)
83
91
  return {"path": str(package.path), "revision": package.revision, "scope": scope,
84
92
  "coordinate_systems": {"block": "top-level body blocks; a table is one block",
@@ -193,7 +201,8 @@ def delete_docx_blocks(source: str | Path, output: str | Path, *, revision: str,
193
201
  operations=[{"op": "delete_blocks", "from": first, "to": last}])
194
202
 
195
203
 
196
- def verify_docx(source: str | Path, *, expectations: list[dict] | None = None) -> dict:
204
+ def verify_docx(source: str | Path, *, expectations: list[dict] | None = None,
205
+ original: str | Path | None = None, base_dir: str | Path = ".") -> dict:
197
206
  """Check package structure and exact positions; absent assertions do not certify content."""
198
207
  package = DocxPackage(source)
199
208
  structure = _Structure(package.root)
@@ -203,17 +212,18 @@ def verify_docx(source: str | Path, *, expectations: list[dict] | None = None) -
203
212
  if not isinstance(expectations, list):
204
213
  raise TypeError("expectations must be an array")
205
214
  mismatches = []
215
+ from .document_docx_expectations import expected_text
206
216
  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")
217
+ expected, mode = expected_text(expectation, index, base_dir)
212
218
  actual = _text(structure.target(expectation["target"]))
213
219
  passed = actual == expected if mode == "equals" else expected in actual if mode == "contains" else expected not in actual
214
220
  if not passed:
215
221
  mismatches.append({"index": index, "target": expectation["target"], "mode": mode, "expected": expected,
216
222
  "actual": actual[:1000], "actual_truncated": len(actual) > 1000})
223
+ preservation = compare_docx_preservation(package, DocxPackage(original)) if original is not None else None
217
224
  return {"path": str(package.path), "revision": package.revision, "structure_verified": True,
218
- "content_verified": bool(expectations) and not mismatches, "verified": not mismatches,
225
+ "content_verified": bool(expectations) and not mismatches,
226
+ "preservation_verified": preservation is not None and preservation["verified"],
227
+ "preservation": preservation,
228
+ "verified": not mismatches and (preservation is None or preservation["verified"]),
219
229
  "expectation_count": len(expectations), "mismatches": mismatches}
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from .document_docx_edit import _apply_operation
6
6
  from .document_docx_package import DocxPackage
7
7
  from .document_docx_structure import _Structure, _index
8
+ from .document_docx_table import parse_table
8
9
 
9
10
 
10
11
  def set_docx_cell(source: str | Path, output: str | Path, *, revision: str,
@@ -19,15 +20,27 @@ def set_docx_cell(source: str | Path, output: str | Path, *, revision: str,
19
20
 
20
21
 
21
22
  def insert_docx_rows(source: str | Path, output: str | Path, *, revision: str,
22
- table: int, before: int, rows: list[list], template_row: int,
23
+ table: int, rows: list[list], template_row: int,
24
+ before: int | None = None, append: bool = False,
23
25
  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
+ """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)):
26
32
  _index(value, name)
33
+ if before is not None:
34
+ _index(before, "before")
27
35
  if not isinstance(rows, list) or not 1 <= len(rows) <= 200:
28
36
  raise ValueError("rows must contain 1-200 row arrays")
29
37
  package = DocxPackage(source)
30
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
31
44
  _apply_operation(package.root, {"op": "insert_rows", "table": table, "before": before,
32
45
  "template_row": template_row, "count": len(rows), "rows": rows})
33
46
  return {**package.save(output, overwrite=overwrite), "inserted_rows": len(rows)}
@@ -0,0 +1,113 @@
1
+ import base64
2
+ from io import BytesIO
3
+ from zipfile import ZipFile
4
+
5
+ from docx import Document
6
+ from docx.oxml import OxmlElement
7
+ from docx.oxml.ns import qn
8
+ import pytest
9
+
10
+ from licos_dev_sdk import inspect_docx, verify_docx
11
+
12
+
13
+ def field_run(paragraph, tag, value):
14
+ element = OxmlElement('w:' + tag)
15
+ if tag == 'instrText':
16
+ element.text = value
17
+ else:
18
+ element.set(qn('w:fldCharType'), value)
19
+ paragraph.add_run()._r.append(element)
20
+
21
+
22
+ def test_inspect_native_fields_images_and_tables_without_rewriting(tmp_path):
23
+ path = tmp_path / 'report.docx'
24
+ doc = Document()
25
+ p = doc.add_paragraph()
26
+ field_run(p, 'fldChar', 'begin')
27
+ field_run(p, 'instrText', ' TO')
28
+ field_run(p, 'instrText', 'C \\o "1-3" ')
29
+ field_run(p, 'fldChar', 'separate')
30
+ p.add_run('Cached table of contents')
31
+ field_run(p, 'fldChar', 'end')
32
+ simple = OxmlElement('w:fldSimple')
33
+ simple.set(qn('w:instr'), 'PAGE')
34
+ doc.sections[0].footer.paragraphs[0]._p.append(simple)
35
+ png = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aTtQAAAAASUVORK5CYII=')
36
+ doc.add_picture(BytesIO(png))
37
+ doc.sections[0].header.paragraphs[0].add_run().add_picture(BytesIO(png))
38
+ doc.add_table(rows=3, cols=2)
39
+ update = OxmlElement('w:updateFields')
40
+ update.set(qn('w:val'), 'true')
41
+ doc.settings.element.append(update)
42
+ doc.save(path)
43
+ original = path.read_bytes()
44
+ report = inspect_docx(path, limit=1)
45
+ assert report['toc_count'] == 1 and report['field_count'] == 2
46
+ assert report['image_count'] == 2 and report['table_count'] == 1
47
+ assert report['fields']['items'][0]['complete']
48
+ assert report['fields']['items'][0]['instruction'] == ' TOC \\o "1-3" '
49
+ assert report['images']['next_offset'] == 2
50
+ assert report['tables']['items'][0] == {'part': 'word/document.xml', 'row_count': 3, 'column_count': 2}
51
+ second = inspect_docx(path, scope='images', offset=2)
52
+ assert second['images']['items'][0]['part'].startswith('word/header')
53
+ assert second['images']['items'][0]['status'] == 'embedded'
54
+ assert 'fields' not in second
55
+ assert report['update_fields_on_open'] and not report['rendered_layout_verified']
56
+ assert path.read_bytes() == original
57
+ with ZipFile(path) as archive:
58
+ parts = {name: archive.read(name) for name in archive.namelist()}
59
+ image_part = report['images']['items'][0]['package_part']
60
+ parts.pop(image_part)
61
+ with ZipFile(path, 'w') as archive:
62
+ for name, data in parts.items():
63
+ archive.writestr(name, data)
64
+ assert inspect_docx(path)['images']['items'][0]['status'] == 'missing_part'
65
+
66
+
67
+ def test_incomplete_toc_and_external_or_missing_image_relationships(tmp_path):
68
+ path = tmp_path / 'linked.docx'
69
+ doc = Document()
70
+ p = doc.add_paragraph()
71
+ field_run(p, 'fldChar', 'begin')
72
+ field_run(p, 'instrText', 'TOC')
73
+ rid = doc.part.relate_to('https://example.com/image.png',
74
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', is_external=True)
75
+ for relationship in (rid, 'missing'):
76
+ blip = OxmlElement('a:blip')
77
+ blip.set(qn('r:link'), relationship)
78
+ p.add_run()._r.append(blip)
79
+ doc.save(path)
80
+ report = inspect_docx(path)
81
+ assert not report['fields']['items'][0]['complete']
82
+ assert [image['status'] for image in report['images']['items']] == ['external', 'missing_relationship']
83
+
84
+
85
+ def test_batch_file_assertions_resolve_from_explicit_base_and_keep_exact_text(tmp_path, monkeypatch):
86
+ path = tmp_path / 'report.docx'
87
+ text = ' 中文🙂\tA\n' * 90 + ' '
88
+ doc = Document()
89
+ doc.add_paragraph(text)
90
+ doc.save(path)
91
+ expected = tmp_path / 'expected.txt'
92
+ expected.write_text(text, encoding='utf-8')
93
+ monkeypatch.chdir(tmp_path.parent)
94
+ assertion = {'target': {'block': 1}, 'text_file': 'expected.txt'}
95
+ assert verify_docx(path, expectations=[assertion], base_dir=tmp_path)['verified']
96
+ assertion['text_file'] = str(expected)
97
+ assert verify_docx(path, expectations=[assertion])['verified']
98
+
99
+
100
+ @pytest.mark.parametrize(('assertion', 'error'), [
101
+ ({'text-file': 'x.txt'}, 'unknown fields text-file'),
102
+ ({'text': 'x', 'text_file': 'x.txt'}, 'exactly one'),
103
+ ({'text_file': None}, 'text_file must be'),
104
+ ({'text_file': 'missing.txt'}, 'expectation 1.text_file'),
105
+ ({'text': '', 'mode': []}, 'mode must be'),
106
+ ])
107
+ def test_invalid_assertions_report_precise_schema_field(tmp_path, assertion, error):
108
+ path = tmp_path / 'report.docx'
109
+ doc = Document()
110
+ doc.add_paragraph('')
111
+ doc.save(path)
112
+ with pytest.raises(ValueError, match=error):
113
+ verify_docx(path, expectations=[{'target': {'block': 1}, **assertion}], base_dir=tmp_path)
@@ -202,3 +202,87 @@ def test_simple_cell_set_uses_revision_and_preserves_style(source, tmp_path):
202
202
  set_docx_cell(source, output, revision=rev(source), table=1, row=2, column=1, text="新值")
203
203
  assert Document(output).tables[0].cell(1, 0).text == "新值"
204
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
File without changes