docxdriver 0.3.3__tar.gz → 0.4.1__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 (87) hide show
  1. {docxdriver-0.3.3 → docxdriver-0.4.1}/Cargo.lock +23 -5
  2. {docxdriver-0.3.3 → docxdriver-0.4.1}/Cargo.toml +1 -1
  3. {docxdriver-0.3.3 → docxdriver-0.4.1}/PKG-INFO +32 -26
  4. docxdriver-0.4.1/README.md +87 -0
  5. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/Cargo.toml +7 -0
  6. docxdriver-0.4.1/crates/docxdriver-core/LICENSE +21 -0
  7. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/api.rs +16 -1
  8. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/comments.rs +33 -10
  9. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/equations.rs +4 -2
  10. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/headers.rs +8 -4
  11. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/layout.rs +14 -4
  12. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/mod.rs +24 -2
  13. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/readonly.rs +4 -1
  14. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/revisions.rs +133 -14
  15. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/tables.rs +16 -6
  16. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/typed.rs +8 -21
  17. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/document.rs +32 -22
  18. docxdriver-0.4.1/crates/docxdriver-core/src/facade.rs +200 -0
  19. docxdriver-0.4.1/crates/docxdriver-core/src/integrity/mod.rs +1519 -0
  20. docxdriver-0.4.1/crates/docxdriver-core/src/integrity/schema.rs +512 -0
  21. docxdriver-0.4.1/crates/docxdriver-core/src/integrity/tree.rs +503 -0
  22. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/lib.rs +5 -1
  23. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/package.rs +54 -18
  24. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/segments.rs +238 -0
  25. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-python/Cargo.toml +2 -0
  26. docxdriver-0.4.1/crates/docxdriver-python/README.md +87 -0
  27. {docxdriver-0.3.3 → docxdriver-0.4.1/crates/docxdriver-python}/python/docxdriver/__init__.py +2 -1
  28. docxdriver-0.4.1/crates/docxdriver-python/python/docxdriver/document.py +126 -0
  29. docxdriver-0.4.1/crates/docxdriver-python/python/docxdriver/plan.py +570 -0
  30. docxdriver-0.4.1/crates/docxdriver-python/src/lib.rs +199 -0
  31. docxdriver-0.4.1/crates/docxdriver-python/tests/test_document.py +75 -0
  32. docxdriver-0.4.1/crates/docxdriver-python/tests/test_plan.py +40 -0
  33. {docxdriver-0.3.3/crates/docxdriver-python → docxdriver-0.4.1}/python/docxdriver/__init__.py +2 -1
  34. docxdriver-0.4.1/python/docxdriver/document.py +126 -0
  35. docxdriver-0.4.1/python/docxdriver/plan.py +570 -0
  36. docxdriver-0.3.3/README.md +0 -81
  37. docxdriver-0.3.3/crates/docxdriver-core/examples/list_scaling.rs +0 -71
  38. docxdriver-0.3.3/crates/docxdriver-core/examples/plan_scaling.rs +0 -43
  39. docxdriver-0.3.3/crates/docxdriver-core/src/bin/generate_typed_schema.rs +0 -172
  40. docxdriver-0.3.3/crates/docxdriver-core/src/html/README.md +0 -14
  41. docxdriver-0.3.3/crates/docxdriver-core/tests/README.md +0 -35
  42. docxdriver-0.3.3/crates/docxdriver-core/tests/fixtures/projection-parity/features.json +0 -33
  43. docxdriver-0.3.3/crates/docxdriver-core/tests/fixtures/projection-parity/parity.docx +0 -0
  44. docxdriver-0.3.3/crates/docxdriver-core/tests/fixtures/projection-parity/prior-projection.json +0 -1393
  45. docxdriver-0.3.3/crates/docxdriver-core/tests/fixtures/ts-reference.docx +0 -0
  46. docxdriver-0.3.3/crates/docxdriver-core/tests/formatting_fidelity.rs +0 -1148
  47. docxdriver-0.3.3/crates/docxdriver-core/tests/html_e2e.rs +0 -983
  48. docxdriver-0.3.3/crates/docxdriver-core/tests/interop.rs +0 -99
  49. docxdriver-0.3.3/crates/docxdriver-core/tests/package_layout.rs +0 -126
  50. docxdriver-0.3.3/crates/docxdriver-core/tests/projection_parity.rs +0 -531
  51. docxdriver-0.3.3/crates/docxdriver-core/tests/spike.rs +0 -44
  52. docxdriver-0.3.3/crates/docxdriver-core/tests/typed_api.rs +0 -2553
  53. docxdriver-0.3.3/crates/docxdriver-python/README.md +0 -81
  54. docxdriver-0.3.3/crates/docxdriver-python/python/docxdriver/repl/__init__.py +0 -7
  55. docxdriver-0.3.3/crates/docxdriver-python/python/docxdriver/repl/host.py +0 -562
  56. docxdriver-0.3.3/crates/docxdriver-python/python/docxdriver/repl/prelude.py +0 -3
  57. docxdriver-0.3.3/crates/docxdriver-python/python/docxdriver/repl/quotes.py +0 -325
  58. docxdriver-0.3.3/crates/docxdriver-python/src/lib.rs +0 -62
  59. docxdriver-0.3.3/crates/docxdriver-python/tests/test_host.py +0 -357
  60. docxdriver-0.3.3/python/docxdriver/repl/__init__.py +0 -7
  61. docxdriver-0.3.3/python/docxdriver/repl/host.py +0 -562
  62. docxdriver-0.3.3/python/docxdriver/repl/prelude.py +0 -3
  63. docxdriver-0.3.3/python/docxdriver/repl/quotes.py +0 -325
  64. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/README.md +0 -0
  65. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/assets/blank.docx +0 -0
  66. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/lists.rs +0 -0
  67. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/reading.rs +0 -0
  68. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/sections.rs +0 -0
  69. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/commands/table_structure.rs +0 -0
  70. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/assets.rs +0 -0
  71. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/build.rs +0 -0
  72. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/chrome.rs +0 -0
  73. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/compact.rs +0 -0
  74. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/ctx.rs +0 -0
  75. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/input.rs +0 -0
  76. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/mathml_omml.rs +0 -0
  77. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/mod.rs +0 -0
  78. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/omml_latex.rs +0 -0
  79. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/omml_mathml.rs +0 -0
  80. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/parse.rs +0 -0
  81. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/render.rs +0 -0
  82. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/styles.rs +0 -0
  83. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/html/surface.rs +0 -0
  84. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/media.rs +0 -0
  85. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-core/src/outcome.rs +0 -0
  86. {docxdriver-0.3.3 → docxdriver-0.4.1}/crates/docxdriver-python/.gitignore +0 -0
  87. {docxdriver-0.3.3 → docxdriver-0.4.1}/pyproject.toml +0 -0
@@ -416,7 +416,7 @@ dependencies = [
416
416
 
417
417
  [[package]]
418
418
  name = "docxdriver-cli"
419
- version = "0.3.3"
419
+ version = "0.4.1"
420
420
  dependencies = [
421
421
  "clap",
422
422
  "docxdriver-core",
@@ -427,10 +427,11 @@ dependencies = [
427
427
 
428
428
  [[package]]
429
429
  name = "docxdriver-core"
430
- version = "0.3.3"
430
+ version = "0.4.1"
431
431
  dependencies = [
432
432
  "hex",
433
433
  "latex2mathml",
434
+ "quick-xml",
434
435
  "regex-lite",
435
436
  "schemars 0.8.22",
436
437
  "serde",
@@ -444,7 +445,7 @@ dependencies = [
444
445
 
445
446
  [[package]]
446
447
  name = "docxdriver-mcp"
447
- version = "0.3.3"
448
+ version = "0.4.1"
448
449
  dependencies = [
449
450
  "axum",
450
451
  "cap-std",
@@ -460,18 +461,26 @@ dependencies = [
460
461
  "tokio-util",
461
462
  ]
462
463
 
464
+ [[package]]
465
+ name = "docxdriver-monty-repl"
466
+ version = "0.4.1"
467
+ dependencies = [
468
+ "docxdriver-core",
469
+ ]
470
+
463
471
  [[package]]
464
472
  name = "docxdriver-python"
465
- version = "0.3.3"
473
+ version = "0.4.1"
466
474
  dependencies = [
467
475
  "docxdriver-core",
468
476
  "pyo3",
477
+ "serde",
469
478
  "serde_json",
470
479
  ]
471
480
 
472
481
  [[package]]
473
482
  name = "docxdriver-wasm"
474
- version = "0.3.3"
483
+ version = "0.4.1"
475
484
  dependencies = [
476
485
  "docxdriver-core",
477
486
  "js-sys",
@@ -1180,6 +1189,15 @@ dependencies = [
1180
1189
  "syn 2.0.118",
1181
1190
  ]
1182
1191
 
1192
+ [[package]]
1193
+ name = "quick-xml"
1194
+ version = "0.42.0"
1195
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1196
+ checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
1197
+ dependencies = [
1198
+ "memchr",
1199
+ ]
1200
+
1183
1201
  [[package]]
1184
1202
  name = "quote"
1185
1203
  version = "1.0.46"
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/docxdriver-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.3"
6
+ version = "0.4.1"
7
7
  edition = "2021"
8
8
  license = "MIT"
9
9
  repository = "https://github.com/jhnoel/docxdriver"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docxdriver
3
- Version: 0.3.3
3
+ Version: 0.4.1
4
4
  Summary: Python SDK for the native Rust DOCX engine
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -18,38 +18,44 @@ Install from PyPI:
18
18
  python -m pip install docxdriver
19
19
  ```
20
20
 
21
- The core API exposes `execute_request`, `canonical_plan_json`, and the convenience
22
- functions `run_command`, `run_plan`, and `run_request`. Results preserve document
23
- contents as Python `bytes`.
24
-
25
- The optional sandbox REPL harness is available as a subpackage in the same
26
- distribution:
21
+ `Document` works on `.docx` bytes; typed plan builders live in
22
+ `docxdriver.plan`:
27
23
 
28
24
  ```python
29
- from docxdriver.repl import NativeBridge, PRELUDE, REFERENCE
25
+ from pathlib import Path
26
+ from docxdriver import Document, create
27
+ from docxdriver.plan import Plan, ReplaceText
28
+
29
+ doc = Document(Path("contract.docx").read_bytes())
30
+ doc.markup() # body with the ids plans address
31
+ doc.comments(); doc.revisions(); doc.styles(); doc.assets()
32
+ doc.find("thirty")
33
+ review = doc.review(Plan(author="Reviewer", ops=[
34
+ ReplaceText(at="0F537164", select="thirty", with_="sixty"),
35
+ ]))
36
+ review.report # preview: each op's outcome
37
+ Path("contract.docx").write_bytes(doc.commit(review.key).data)
38
+
39
+ new = create(paragraphs=["Hello"]) # or create(html="<p>Hello</p>")
30
40
  ```
31
41
 
32
- `NativeBridge` provides async `init`, `call`, and `reset` requests for a host
33
- running a Python sandbox. The sandbox receives authoring classes and async
34
- wrappers from `PRELUDE`; `REFERENCE` provides their API documentation. The
35
- sandbox itself does not import the native extension. Native work runs through
36
- host callbacks outside the server event loop.
37
-
38
- The host confines reads and writes to the provided workspace, verifies source
39
- hashes and candidate output before commit, and performs atomic file replacement
40
- with read-back verification. Commit keys are bounded and single-use. Cancellation
41
- is drained before another host call so a write callback cannot be abandoned.
42
+ Commit keys are single-use. Natively `review.key` carries the key; in a
43
+ sandbox it is `None` and the host shows the key only to the model, so a
44
+ commit proves the review was read. Sandbox hosts written in Python build on
45
+ the stateless `review_plan(data, plan_json)` and
46
+ `commit_plan(data, plan_json, key)`, keeping each plan under a key they issue.
42
47
 
43
- Set `DOCXDRIVER_QUOTE_AUDIT_COMMENTS=1` to enable structured Quote/Term/Inline plan
44
- values and protected quotation audit comments. The provenance policy defaults to
45
- `permissive`; `controlled` and `authoritative` fail closed.
48
+ Engine diagnostics raise `ValueError`. The lower-level `execute_request`,
49
+ `canonical_plan_json`, `run_command`, `run_plan` and `run_request` remain.
46
50
 
47
- The prelude and API reference are generated from shared Pi templates. After
48
- changing those templates, regenerate them from the repository root:
51
+ `plan.py` and `document.py` stay within Monty's Python subset. Rust hosts get
52
+ the same text as `docxdriver_monty_repl::PLAN` and `DOCUMENT`, so sandbox code
53
+ and native code are interchangeable. The op dataclasses are generated from the
54
+ core schema; regenerate from the repository root with
55
+ `python scripts/generate-python-plan.py`.
49
56
 
50
- ```sh
51
- python scripts/generate-docxdriver-repl-prelude.py
52
- ```
57
+ Version 0.4.0 removed the `docxdriver.repl` sandbox host from this package. Its
58
+ source remains in `experimental/python-repl-host` for hosts to vendor.
53
59
 
54
60
  Build and install a wheel from the repository root:
55
61
 
@@ -0,0 +1,87 @@
1
+ # docxdriver
2
+
3
+ Python SDK for the Rust DOCX engine. It supports Python 3.10 and later and has no
4
+ Python runtime dependencies.
5
+
6
+ Install from PyPI:
7
+
8
+ ```sh
9
+ python -m pip install docxdriver
10
+ ```
11
+
12
+ `Document` works on `.docx` bytes; typed plan builders live in
13
+ `docxdriver.plan`:
14
+
15
+ ```python
16
+ from pathlib import Path
17
+ from docxdriver import Document, create
18
+ from docxdriver.plan import Plan, ReplaceText
19
+
20
+ doc = Document(Path("contract.docx").read_bytes())
21
+ doc.markup() # body with the ids plans address
22
+ doc.comments(); doc.revisions(); doc.styles(); doc.assets()
23
+ doc.find("thirty")
24
+ review = doc.review(Plan(author="Reviewer", ops=[
25
+ ReplaceText(at="0F537164", select="thirty", with_="sixty"),
26
+ ]))
27
+ review.report # preview: each op's outcome
28
+ Path("contract.docx").write_bytes(doc.commit(review.key).data)
29
+
30
+ new = create(paragraphs=["Hello"]) # or create(html="<p>Hello</p>")
31
+ ```
32
+
33
+ Commit keys are single-use. Natively `review.key` carries the key; in a
34
+ sandbox it is `None` and the host shows the key only to the model, so a
35
+ commit proves the review was read. Sandbox hosts written in Python build on
36
+ the stateless `review_plan(data, plan_json)` and
37
+ `commit_plan(data, plan_json, key)`, keeping each plan under a key they issue.
38
+
39
+ Engine diagnostics raise `ValueError`. The lower-level `execute_request`,
40
+ `canonical_plan_json`, `run_command`, `run_plan` and `run_request` remain.
41
+
42
+ `plan.py` and `document.py` stay within Monty's Python subset. Rust hosts get
43
+ the same text as `docxdriver_monty_repl::PLAN` and `DOCUMENT`, so sandbox code
44
+ and native code are interchangeable. The op dataclasses are generated from the
45
+ core schema; regenerate from the repository root with
46
+ `python scripts/generate-python-plan.py`.
47
+
48
+ Version 0.4.0 removed the `docxdriver.repl` sandbox host from this package. Its
49
+ source remains in `experimental/python-repl-host` for hosts to vendor.
50
+
51
+ Build and install a wheel from the repository root:
52
+
53
+ ```sh
54
+ python -m pip install "maturin>=1.9,<2"
55
+ maturin build --profile python --locked --manifest-path crates/docxdriver-python/Cargo.toml --out /tmp/docxdriver-wheels
56
+ python -m pip install /tmp/docxdriver-wheels/*.whl
57
+ ```
58
+
59
+ ## Publish to PyPI
60
+
61
+ The `Publish Python SDK` GitHub Actions workflow builds a source distribution
62
+ and wheels for Linux x86_64 and aarch64, macOS universal2, and Windows x86_64
63
+ when a `v*` tag is pushed. The tag must match the version in the root
64
+ `Cargo.toml` workspace package.
65
+
66
+ Before the first release, configure a PyPI trusted publisher for the
67
+ `docxdriver` project with these values:
68
+
69
+ - Owner: `jhnoel`
70
+ - Repository: `docxdriver`
71
+ - Workflow filename: `pypi.yml`
72
+ - GitHub environment: `pypi`
73
+
74
+ Create the `pypi` GitHub environment under the repository settings if you want
75
+ to add release approvals. Then update the workspace version, commit the change,
76
+ and push the matching tag (for example, `v0.2.7`). GitHub Actions publishes the
77
+ built distributions using PyPI's trusted publishing; no long-lived PyPI token is
78
+ needed.
79
+
80
+ The `python` Cargo profile keeps release optimizations and enables panic unwinding
81
+ so PyO3 can turn Rust panics into Python exceptions. ABI3 wheels support Python
82
+ 3.10 and later on the built operating system and architecture. To run the Python
83
+ SDK and host tests after installing the wheel:
84
+
85
+ ```sh
86
+ python -m unittest discover -s crates/docxdriver-python/tests
87
+ ```
@@ -4,7 +4,13 @@ description = "Artifact-first DOCX engine: (bytes, DocxCommand JSON) -> (bytes,
4
4
  version.workspace = true
5
5
  edition.workspace = true
6
6
  license.workspace = true
7
+ repository.workspace = true
7
8
  readme = "README.md"
9
+ keywords = ["docx", "ooxml", "word", "document", "track-changes"]
10
+ categories = ["text-processing", "parser-implementations"]
11
+ # Library sources and the runtime template only; tests, examples and the
12
+ # schema generator stay in the repository.
13
+ include = ["/src/**/*.rs", "!/src/bin/**", "/assets/blank.docx", "/README.md", "/LICENSE"]
8
14
 
9
15
  [dependencies]
10
16
  xmloxide = { version = "0.4.3", default-features = false }
@@ -18,5 +24,6 @@ sha2 = "0.10"
18
24
  hex = "0.4"
19
25
  toml = "0.8"
20
26
  schemars = "0.8"
27
+ quick-xml = "0.42.0"
21
28
 
22
29
  [dev-dependencies]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 docxdriver contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1957,7 +1957,22 @@ fn fold_typed(
1957
1957
  crate::outcome::Outcome::ok("plan applied", serde_json::json!({})),
1958
1958
  &ctx,
1959
1959
  );
1960
- (report, out.bytes, None)
1960
+ if out.status != crate::outcome::Status::Ok {
1961
+ return (
1962
+ report.clone(),
1963
+ None,
1964
+ Some((diag("package_write", out.summary, None), report)),
1965
+ );
1966
+ }
1967
+ // Review and commit apply the same gate to the same bytes, so a plan
1968
+ // that reviews clean commits clean.
1969
+ if let Some(bytes) = &out.bytes {
1970
+ if let Err(failure) = crate::integrity::gate(input, bytes) {
1971
+ let diagnostic = diag("integrity_regression", failure.summary(), None);
1972
+ return (report.clone(), None, Some((diagnostic, report)));
1973
+ }
1974
+ }
1975
+ (report, if commit { out.bytes } else { None }, None)
1961
1976
  }
1962
1977
  /// When a selector matched nothing but matches the same paragraph as it was
1963
1978
  /// before the plan, name the earlier operations that changed that text:
@@ -28,7 +28,6 @@ const EXTENDED_PART: &str = "word/commentsExtended.xml";
28
28
  const IDS_PART: &str = "word/commentsIds.xml";
29
29
  const EXTENSIBLE_PART: &str = "word/commentsExtensible.xml";
30
30
  const CONTENT_TYPES_PART: &str = "[Content_Types].xml";
31
- const DOCUMENT_RELS_PART: &str = "word/_rels/document.xml.rels";
32
31
 
33
32
  const W15_NS: &str = "http://schemas.microsoft.com/office/word/2012/wordml";
34
33
  const W14_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordml";
@@ -104,15 +103,18 @@ fn register_part(
104
103
  }
105
104
  }
106
105
 
107
- // word/_rels/document.xml.rels relationship
108
- let mut rels = parse_part(package, DOCUMENT_RELS_PART, "")?;
106
+ // The main document part's relationship.
107
+ let rels_part = super::document_rels_part(package);
108
+ let mut rels = parse_part(package, &rels_part, "")?;
109
109
  let root = rels
110
110
  .doc
111
111
  .root_element()
112
112
  .ok_or_else(|| Outcome::error("malformed document.xml.rels"))?;
113
113
  let target = part.strip_prefix("word/").unwrap_or(part);
114
114
  let exists = rels.doc.children(root).any(|child| {
115
- rels.is_local(child, "Relationship") && rels.doc.attribute(child, "Target") == Some(target)
115
+ rels.is_local(child, "Relationship")
116
+ && rels.doc.attribute(child, "Target") == Some(target)
117
+ && rels.doc.attribute(child, "Type") == Some(rel_type)
116
118
  });
117
119
  if !exists {
118
120
  let mut max_rid = 0u64;
@@ -132,7 +134,7 @@ fn register_part(
132
134
  rels.doc.set_attribute(node, "Type", rel_type);
133
135
  rels.doc.set_attribute(node, "Target", target);
134
136
  rels.doc.append_child(root, node);
135
- package.set(DOCUMENT_RELS_PART, rels.serialize());
137
+ package.set(&rels_part, rels.serialize());
136
138
  }
137
139
  Ok(())
138
140
  }
@@ -279,14 +281,35 @@ fn next_para_id(records: &[CommentRecord], extra_occupied: &HashSet<u64>) -> u64
279
281
  }
280
282
  }
281
283
 
282
- fn document_para_ids(xml: &DocxXml) -> HashSet<u64> {
283
- xml.all_paragraphs()
284
+ /// Paragraph ids in use: the in-memory document's (it may carry repaired or
285
+ /// new ids not yet written) and every `paraId` attribute in any XML part —
286
+ /// other comment paragraphs, headers, footers and notes. A paragraph id
287
+ /// names one paragraph across the whole document.
288
+ fn occupied_para_ids(package: &Package, xml: &DocxXml) -> HashSet<u64> {
289
+ static PARA_ID: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
290
+ let pattern = PARA_ID.get_or_init(|| {
291
+ regex_lite::Regex::new(r#"paraId(?:Parent)?\s*=\s*["']([0-9A-Fa-f]{8})["']"#).unwrap()
292
+ });
293
+ let mut occupied: HashSet<u64> = xml
294
+ .all_paragraphs()
284
295
  .into_iter()
285
296
  .filter_map(|node| {
286
297
  xml.para_id(node)
287
298
  .and_then(|value| u64::from_str_radix(value, 16).ok())
288
299
  })
289
- .collect()
300
+ .collect();
301
+ for (name, bytes) in package.parts() {
302
+ if !name.to_ascii_lowercase().ends_with(".xml") {
303
+ continue;
304
+ }
305
+ let text = String::from_utf8_lossy(bytes);
306
+ occupied.extend(
307
+ pattern
308
+ .captures_iter(&text)
309
+ .filter_map(|c| u64::from_str_radix(&c[1], 16).ok()),
310
+ );
311
+ }
312
+ occupied
290
313
  }
291
314
 
292
315
  /// Deterministic 8-hex durable id (FNV-1a over identifying fields), clamped
@@ -447,7 +470,7 @@ pub(crate) fn anchor_comment_at(
447
470
  let comments = parse_part(package, COMMENTS_PART, EMPTY_COMMENTS)?;
448
471
  let records = comment_records(&comments);
449
472
  let id = next_comment_id(&records);
450
- let para_id = format!("{:08X}", next_para_id(&records, &document_para_ids(xml)));
473
+ let para_id = format!("{:08X}", next_para_id(&records, &occupied_para_ids(package, xml)));
451
474
 
452
475
  insert_anchor_markers(xml, paragraph, start, end, id)?;
453
476
  append_comment_record(package, id, &para_id, None, author, date, text)?;
@@ -736,7 +759,7 @@ pub fn reply_comment_typed(
736
759
  let parent_para_id = parent.para_id.clone();
737
760
  let parent_id = parent.id.clone();
738
761
  let id = next_comment_id(&records);
739
- let para_id = format!("{:08X}", next_para_id(&records, &document_para_ids(xml)));
762
+ let para_id = format!("{:08X}", next_para_id(&records, &occupied_para_ids(package, xml)));
740
763
  append_comment_record(
741
764
  package,
742
765
  id,
@@ -101,6 +101,7 @@ pub(super) fn replace(
101
101
  xml.doc.insert_before(target, holder);
102
102
  xml.doc.detach(target);
103
103
  xml.doc.append_child(deletion, target);
104
+ crate::segments::mark_deleted_leaves(xml, target, true);
104
105
  let ins_id = xml.next_revision_id();
105
106
  xml.doc.set_attribute(holder, "w:id", &ins_id.to_string());
106
107
  xml.doc.set_attribute(holder, "w:author", author);
@@ -110,7 +111,7 @@ pub(super) fn replace(
110
111
  xml.doc.detach(child);
111
112
  xml.doc.insert_before(target, child);
112
113
  }
113
- xml.doc.remove_node(target);
114
+ crate::segments::remove_content(xml, target);
114
115
  xml.doc.remove_node(holder);
115
116
  }
116
117
  Ok((
@@ -156,8 +157,9 @@ pub(super) fn delete(
156
157
  xml.doc.insert_before(target, deletion);
157
158
  xml.doc.detach(target);
158
159
  xml.doc.append_child(deletion, target);
160
+ crate::segments::mark_deleted_leaves(xml, target, true);
159
161
  } else {
160
- xml.doc.remove_node(target);
162
+ crate::segments::remove_content(xml, target);
161
163
  }
162
164
  Ok((
163
165
  format!("deleted equation {} in paragraph {at}", index + 1),
@@ -13,7 +13,6 @@ use crate::package::Package;
13
13
  use super::layout::ensure_sect_pr;
14
14
 
15
15
  const CONTENT_TYPES_PART: &str = "[Content_Types].xml";
16
- const DOCUMENT_RELS_PART: &str = "word/_rels/document.xml.rels";
17
16
  const SETTINGS_CT: &str =
18
17
  "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
19
18
  const SETTINGS_REL: &str =
@@ -73,13 +72,18 @@ fn ensure_document_rel(
73
72
  "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
74
73
  "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>",
75
74
  );
76
- let mut rels = parse_part(package, DOCUMENT_RELS_PART, default)?;
75
+ let rels_part = super::document_rels_part(package);
76
+ let mut rels = parse_part(package, &rels_part, default)?;
77
77
  let root = rels
78
78
  .doc
79
79
  .root_element()
80
80
  .ok_or_else(|| Outcome::error("malformed document.xml.rels"))?;
81
+ // Reuse only a relationship of the same type: a header id that names a
82
+ // footer relationship makes Word refuse the document.
81
83
  if let Some(existing) = rels.doc.children(root).find(|&child| {
82
- rels.is_local(child, "Relationship") && rels.doc.attribute(child, "Target") == Some(target)
84
+ rels.is_local(child, "Relationship")
85
+ && rels.doc.attribute(child, "Target") == Some(target)
86
+ && rels.doc.attribute(child, "Type") == Some(rel_type)
83
87
  }) {
84
88
  return Ok(rels
85
89
  .doc
@@ -104,7 +108,7 @@ fn ensure_document_rel(
104
108
  rels.doc.set_attribute(node, "Type", rel_type);
105
109
  rels.doc.set_attribute(node, "Target", target);
106
110
  rels.doc.append_child(root, node);
107
- package.set(DOCUMENT_RELS_PART, rels.serialize());
111
+ package.set(&rels_part, rels.serialize());
108
112
  Ok(id)
109
113
  }
110
114
 
@@ -188,10 +188,13 @@ fn ensure_property_child(
188
188
  .iter()
189
189
  .position(|name| *name == local)
190
190
  .map_or(&[][..], |index| &sequence[index + 1..]);
191
- let successor = xml
192
- .doc
193
- .children(parent)
194
- .find(|&child| successors.iter().any(|name| xml.is_w(child, name)));
191
+ // Office extension elements (w14 text effects and the like) close the
192
+ // base sequence, before only the trailing revision record.
193
+ let trailing = sequence.last() == Some(&local);
194
+ let successor = xml.doc.children(parent).find(|&child| {
195
+ successors.iter().any(|name| xml.is_w(child, name))
196
+ || (!trailing && xml.doc.is_element(child) && is_extension(xml, child))
197
+ });
195
198
  if let Some(next) = successor {
196
199
  xml.doc.insert_before(next, node);
197
200
  } else {
@@ -200,6 +203,13 @@ fn ensure_property_child(
200
203
  node
201
204
  }
202
205
 
206
+ /// An element outside WordprocessingML: an Office extension, or the markup
207
+ /// compatibility wrapper that carries one.
208
+ fn is_extension(xml: &DocxXml, node: NodeId) -> bool {
209
+ let ns = xml.doc.node_namespace(node);
210
+ ns.is_some() && ns != Some(crate::document::W_NS)
211
+ }
212
+
203
213
  pub(crate) fn set_child_val(xml: &mut DocxXml, parent: NodeId, local: &str, val: &str) {
204
214
  let node = if matches!(local, "pStyle" | "jc") {
205
215
  ensure_ppr_child(xml, parent, local)
@@ -27,6 +27,8 @@ pub struct ExecCtx {
27
27
  pub now: String,
28
28
  }
29
29
 
30
+ const STRICT_W_NS: &str = "http://purl.oclc.org/ooxml/wordprocessingml/main";
31
+
30
32
  /// The parsed working set the operation fold runs over: one unzip + one
31
33
  /// document.xml parse per typed plan, however many operations follow.
32
34
  pub(crate) struct WorkState {
@@ -62,6 +64,14 @@ impl WorkState {
62
64
  .map_err(|error| Outcome::error(error.to_string()))?;
63
65
  DocxXml::parse(bytes).map_err(Outcome::error)?
64
66
  };
67
+ // ISO Strict markup lives in other namespaces: the engine would read
68
+ // it as empty and write Transitional markup into it.
69
+ if xml.doc.root_element().and_then(|root| xml.doc.node_namespace(root)) == Some(STRICT_W_NS) {
70
+ return Err(Outcome::error(
71
+ "this document is saved as Strict Open XML, which docxdriver does not support; \
72
+ save it as a standard Word Document (.docx) and try again",
73
+ ));
74
+ }
65
75
  let table_positions = table_positions(&xml);
66
76
  let table_handles = table_positions.iter().map(|(node, address)| (address.clone(), *node)).collect();
67
77
  Ok(WorkState {
@@ -93,6 +103,16 @@ pub(crate) fn table_positions(xml: &DocxXml) -> HashMap<xmloxide::tree::NodeId,
93
103
  map
94
104
  }
95
105
 
106
+ /// The relationships part of the main document part (`word/_rels/document.xml.rels`
107
+ /// for the conventional name; a renamed main part has its own).
108
+ pub(crate) fn document_rels_part(package: &crate::package::Package) -> String {
109
+ let main = package.document_part_name();
110
+ match main.rsplit_once('/') {
111
+ Some((dir, file)) => format!("{dir}/_rels/{file}.rels"),
112
+ None => format!("_rels/{main}.rels"),
113
+ }
114
+ }
115
+
96
116
  /// sha256 of the exact input package bytes.
97
117
  pub(crate) fn package_sha256(bytes: &[u8]) -> [u8; 32] {
98
118
  use sha2::{Digest, Sha256};
@@ -100,14 +120,16 @@ pub(crate) fn package_sha256(bytes: &[u8]) -> [u8; 32] {
100
120
  }
101
121
 
102
122
  /// Attach the serialized package to a successful outcome when something
103
- /// actually mutated (and dryRun doesn't withhold it). The one zip write.
123
+ /// actually mutated. The one zip write. A dry run builds the bytes too, so
124
+ /// review runs the same integrity gate as commit; callers withhold them.
104
125
  ///
105
126
  /// Every engine write persists paragraph identity first: existing valid
106
127
  /// `w14:paraId` values are untouched, while missing/invalid/duplicate and
107
128
  /// engine-created paragraphs receive the same deterministic values inspection
108
129
  /// showed — one atomic write of repairs plus content operations.
109
130
  pub(crate) fn emit(mut state: WorkState, outcome: Outcome, ctx: &ExecCtx) -> Outcome {
110
- if outcome.status != crate::outcome::Status::Ok || !state.mutated || ctx.dry_run {
131
+ let _ = ctx;
132
+ if outcome.status != crate::outcome::Status::Ok || !state.mutated {
111
133
  return outcome;
112
134
  }
113
135
  state.flush_lists();
@@ -219,7 +219,10 @@ pub fn create(command: &Value, ctx: &ExecCtx) -> Outcome {
219
219
  return Outcome::ok(summary, result);
220
220
  }
221
221
  match package.to_bytes() {
222
- Ok(bytes) => Outcome::ok_bytes(summary, result, bytes),
222
+ Ok(bytes) => match crate::integrity::gate_created(&bytes) {
223
+ Ok(()) => Outcome::ok_bytes(summary, result, bytes),
224
+ Err(failure) => Outcome::error(failure.summary()),
225
+ },
223
226
  Err(error) => Outcome::error(error.to_string()),
224
227
  }
225
228
  }