docxdriver 0.3.2__tar.gz → 0.4.0__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.2 → docxdriver-0.4.0}/Cargo.lock +13 -5
  2. {docxdriver-0.3.2 → docxdriver-0.4.0}/Cargo.toml +1 -1
  3. {docxdriver-0.3.2 → docxdriver-0.4.0}/PKG-INFO +32 -26
  4. docxdriver-0.4.0/README.md +87 -0
  5. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/Cargo.toml +6 -0
  6. docxdriver-0.4.0/crates/docxdriver-core/LICENSE +21 -0
  7. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/api.rs +38 -14
  8. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/layout.rs +11 -4
  9. docxdriver-0.4.0/crates/docxdriver-core/src/commands/lists.rs +531 -0
  10. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/mod.rs +38 -9
  11. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/readonly.rs +1 -3
  12. docxdriver-0.4.0/crates/docxdriver-core/src/commands/sections.rs +183 -0
  13. docxdriver-0.4.0/crates/docxdriver-core/src/commands/table_structure.rs +563 -0
  14. docxdriver-0.4.0/crates/docxdriver-core/src/commands/tables.rs +925 -0
  15. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/document.rs +109 -16
  16. docxdriver-0.4.0/crates/docxdriver-core/src/facade.rs +200 -0
  17. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/build.rs +41 -22
  18. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/ctx.rs +4 -1
  19. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/input.rs +5 -1
  20. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/render.rs +6 -5
  21. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/lib.rs +4 -1
  22. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-python/Cargo.toml +2 -0
  23. docxdriver-0.4.0/crates/docxdriver-python/README.md +87 -0
  24. {docxdriver-0.3.2 → docxdriver-0.4.0/crates/docxdriver-python}/python/docxdriver/__init__.py +2 -1
  25. docxdriver-0.4.0/crates/docxdriver-python/python/docxdriver/document.py +126 -0
  26. docxdriver-0.4.0/crates/docxdriver-python/python/docxdriver/plan.py +570 -0
  27. docxdriver-0.4.0/crates/docxdriver-python/src/lib.rs +199 -0
  28. docxdriver-0.4.0/crates/docxdriver-python/tests/test_document.py +75 -0
  29. docxdriver-0.4.0/crates/docxdriver-python/tests/test_plan.py +40 -0
  30. {docxdriver-0.3.2/crates/docxdriver-python → docxdriver-0.4.0}/python/docxdriver/__init__.py +2 -1
  31. docxdriver-0.4.0/python/docxdriver/document.py +126 -0
  32. docxdriver-0.4.0/python/docxdriver/plan.py +570 -0
  33. docxdriver-0.3.2/README.md +0 -81
  34. docxdriver-0.3.2/crates/docxdriver-core/examples/list_scaling.rs +0 -71
  35. docxdriver-0.3.2/crates/docxdriver-core/examples/plan_scaling.rs +0 -43
  36. docxdriver-0.3.2/crates/docxdriver-core/src/bin/generate_typed_schema.rs +0 -171
  37. docxdriver-0.3.2/crates/docxdriver-core/src/commands/lists.rs +0 -141
  38. docxdriver-0.3.2/crates/docxdriver-core/src/commands/table_structure.rs +0 -100
  39. docxdriver-0.3.2/crates/docxdriver-core/src/commands/tables.rs +0 -128
  40. docxdriver-0.3.2/crates/docxdriver-core/src/html/README.md +0 -14
  41. docxdriver-0.3.2/crates/docxdriver-core/tests/README.md +0 -35
  42. docxdriver-0.3.2/crates/docxdriver-core/tests/fixtures/projection-parity/features.json +0 -33
  43. docxdriver-0.3.2/crates/docxdriver-core/tests/fixtures/projection-parity/parity.docx +0 -0
  44. docxdriver-0.3.2/crates/docxdriver-core/tests/fixtures/projection-parity/prior-projection.json +0 -1393
  45. docxdriver-0.3.2/crates/docxdriver-core/tests/fixtures/ts-reference.docx +0 -0
  46. docxdriver-0.3.2/crates/docxdriver-core/tests/formatting_fidelity.rs +0 -840
  47. docxdriver-0.3.2/crates/docxdriver-core/tests/html_e2e.rs +0 -897
  48. docxdriver-0.3.2/crates/docxdriver-core/tests/interop.rs +0 -99
  49. docxdriver-0.3.2/crates/docxdriver-core/tests/package_layout.rs +0 -126
  50. docxdriver-0.3.2/crates/docxdriver-core/tests/projection_parity.rs +0 -531
  51. docxdriver-0.3.2/crates/docxdriver-core/tests/spike.rs +0 -44
  52. docxdriver-0.3.2/crates/docxdriver-core/tests/typed_api.rs +0 -2499
  53. docxdriver-0.3.2/crates/docxdriver-python/README.md +0 -81
  54. docxdriver-0.3.2/crates/docxdriver-python/python/docxdriver/repl/__init__.py +0 -7
  55. docxdriver-0.3.2/crates/docxdriver-python/python/docxdriver/repl/host.py +0 -562
  56. docxdriver-0.3.2/crates/docxdriver-python/python/docxdriver/repl/prelude.py +0 -3
  57. docxdriver-0.3.2/crates/docxdriver-python/python/docxdriver/repl/quotes.py +0 -325
  58. docxdriver-0.3.2/crates/docxdriver-python/src/lib.rs +0 -62
  59. docxdriver-0.3.2/crates/docxdriver-python/tests/test_host.py +0 -308
  60. docxdriver-0.3.2/python/docxdriver/repl/__init__.py +0 -7
  61. docxdriver-0.3.2/python/docxdriver/repl/host.py +0 -562
  62. docxdriver-0.3.2/python/docxdriver/repl/prelude.py +0 -3
  63. docxdriver-0.3.2/python/docxdriver/repl/quotes.py +0 -325
  64. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/README.md +0 -0
  65. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/assets/blank.docx +0 -0
  66. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/comments.rs +0 -0
  67. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/equations.rs +0 -0
  68. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/headers.rs +0 -0
  69. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/reading.rs +0 -0
  70. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/revisions.rs +0 -0
  71. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/commands/typed.rs +0 -0
  72. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/assets.rs +0 -0
  73. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/chrome.rs +0 -0
  74. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/compact.rs +0 -0
  75. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/mathml_omml.rs +0 -0
  76. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/mod.rs +0 -0
  77. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/omml_latex.rs +0 -0
  78. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/omml_mathml.rs +0 -0
  79. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/parse.rs +0 -0
  80. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/styles.rs +0 -0
  81. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/html/surface.rs +0 -0
  82. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/media.rs +0 -0
  83. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/outcome.rs +0 -0
  84. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/package.rs +0 -0
  85. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-core/src/segments.rs +0 -0
  86. {docxdriver-0.3.2 → docxdriver-0.4.0}/crates/docxdriver-python/.gitignore +0 -0
  87. {docxdriver-0.3.2 → docxdriver-0.4.0}/pyproject.toml +0 -0
@@ -416,7 +416,7 @@ dependencies = [
416
416
 
417
417
  [[package]]
418
418
  name = "docxdriver-cli"
419
- version = "0.3.2"
419
+ version = "0.4.0"
420
420
  dependencies = [
421
421
  "clap",
422
422
  "docxdriver-core",
@@ -427,7 +427,7 @@ dependencies = [
427
427
 
428
428
  [[package]]
429
429
  name = "docxdriver-core"
430
- version = "0.3.2"
430
+ version = "0.4.0"
431
431
  dependencies = [
432
432
  "hex",
433
433
  "latex2mathml",
@@ -444,7 +444,7 @@ dependencies = [
444
444
 
445
445
  [[package]]
446
446
  name = "docxdriver-mcp"
447
- version = "0.3.2"
447
+ version = "0.4.0"
448
448
  dependencies = [
449
449
  "axum",
450
450
  "cap-std",
@@ -460,18 +460,26 @@ dependencies = [
460
460
  "tokio-util",
461
461
  ]
462
462
 
463
+ [[package]]
464
+ name = "docxdriver-monty-repl"
465
+ version = "0.4.0"
466
+ dependencies = [
467
+ "docxdriver-core",
468
+ ]
469
+
463
470
  [[package]]
464
471
  name = "docxdriver-python"
465
- version = "0.3.2"
472
+ version = "0.4.0"
466
473
  dependencies = [
467
474
  "docxdriver-core",
468
475
  "pyo3",
476
+ "serde",
469
477
  "serde_json",
470
478
  ]
471
479
 
472
480
  [[package]]
473
481
  name = "docxdriver-wasm"
474
- version = "0.3.2"
482
+ version = "0.4.0"
475
483
  dependencies = [
476
484
  "docxdriver-core",
477
485
  "js-sys",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/docxdriver-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.2"
6
+ version = "0.4.0"
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.2
3
+ Version: 0.4.0
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 }
@@ -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.
@@ -159,9 +159,9 @@ pub struct RowFormat {
159
159
  #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
160
160
  #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
161
161
  pub enum TableEdit {
162
- InsertRows { row: u32, position: InsertPosition, count: u32 },
162
+ InsertRows { row: u32, position: InsertPosition, count: u32, #[serde(rename = "as", default)] alias: Option<String> },
163
163
  DeleteRows { row: u32, count: u32 },
164
- InsertColumns { column: u32, position: InsertPosition, count: u32 },
164
+ InsertColumns { column: u32, position: InsertPosition, count: u32, #[serde(rename = "as", default)] alias: Option<String> },
165
165
  DeleteColumns { column: u32, count: u32 },
166
166
  MergeCells { row: u32, column: u32, row_span: u32, column_span: u32 },
167
167
  SplitCell { row: u32, column: u32 },
@@ -665,7 +665,9 @@ pub enum EditOp {
665
665
  gutter: Option<Points>,
666
666
  },
667
667
  /// Selective section-property edits. Dimensions are points; omitted fields preserve source.
668
+ SetSectionBoundary { at: ParagraphAddress, #[serde(default)] remove: bool, #[serde(default)] break_type: Option<String> },
668
669
  SetSectionLayout {
670
+ #[serde(default)] section_handle: Option<String>,
669
671
  #[serde(default)]
670
672
  section: Option<u32>,
671
673
  #[serde(default)]
@@ -1480,7 +1482,7 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1480
1482
  return Err("levels requires a new list with 1..9 definitions covering the selected level".into());
1481
1483
  }
1482
1484
  for (i, def) in defs.iter().enumerate() {
1483
- if !["bullet","decimal","lowerLetter","upperLetter","lowerRoman","upperRoman"].contains(&def.format.as_str()) || def.start == Some(0) || def.label.is_empty() || def.label.len() > 255 || def.restart_after.is_some_and(|v| v as usize >= i) {
1485
+ if !["bullet","decimal","lowerLetter","upperLetter","lowerRoman","upperRoman"].contains(&def.format.as_str()) || def.start.is_some_and(|v|v == 0 || v > i32::MAX as u32) || def.label.is_empty() || def.label.len() > 255 || def.restart_after.is_some_and(|v| v as usize >= i) {
1484
1486
  return Err("invalid list level format, label, start or restart ancestor".into());
1485
1487
  }
1486
1488
  if [def.indent_left,def.indent_right,def.first_line_indent].into_iter().flatten().any(|v| v.0.unsigned_abs() > 2_000_000) { return Err("list indentation is out of range".into()); }
@@ -1491,8 +1493,8 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1491
1493
  if def.marker_font.as_ref().is_some_and(|v| v.is_empty()) || def.marker_color.as_ref().is_some_and(|v| v.len() != 6 || !v.bytes().all(|c| c.is_ascii_hexdigit())) { return Err("invalid list marker font or six-digit RGB color".into()); }
1492
1494
  }
1493
1495
  }
1494
- if at.is_empty() || *level > 8 || *start == Some(0) || *num_id == Some(0) {
1495
- return Err("set_list requires paragraphs, level 0..8 and positive IDs/starts".into());
1496
+ if at.is_empty() || *level > 8 || start.is_some_and(|v|v == 0 || v > i32::MAX as u32) || num_id.is_some_and(|v|v == 0 || v > i32::MAX as u32) {
1497
+ return Err("set_list requires paragraphs, level 0..8 and IDs/starts in 1..2147483647".into());
1496
1498
  }
1497
1499
  if *remove && (num_id.is_some() || format.is_some() || start.is_some() || *level != 0) {
1498
1500
  return Err("set_list remove cannot be combined with list settings".into());
@@ -1516,7 +1518,13 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1516
1518
  return Err("at must not be empty".into());
1517
1519
  }
1518
1520
  }
1519
- EditOp::SetSectionLayout { section, page_size, width, height, orientation, columns, column_spacing, column_widths, column_separator, break_type, page_number_format, page_number_start, page_number_continue } => {
1521
+ EditOp::SetSectionBoundary {remove,break_type,..} => {
1522
+ if *remove && break_type.is_some(){return Err("removing a boundary cannot set break_type".into());}
1523
+ if break_type.as_ref().is_some_and(|v|!matches!(v.as_str(),"nextPage"|"continuous"|"oddPage"|"evenPage"|"nextColumn")){return Err("unsupported section break_type".into());}
1524
+ }
1525
+ EditOp::SetSectionLayout { section, section_handle, page_size, width, height, orientation, columns, column_spacing, column_widths, column_separator, break_type, page_number_format, page_number_start, page_number_continue } => {
1526
+ if section.is_some() && section_handle.is_some() {return Err("section and section_handle are mutually exclusive".into());}
1527
+ if section_handle.as_ref().is_some_and(|v|!v.starts_with("section:")){return Err("section_handle must use an inventory handle".into());}
1520
1528
  if section == &Some(0) { return Err("section must be positive".into()); }
1521
1529
  if let Some(size) = page_size { if !matches!(size.as_str(), "letter" | "legal" | "a4" | "a3" | "a5") { return Err("page_size must be letter/legal/a4/a3/a5".into()); } }
1522
1530
  if page_size.is_some() && (width.is_some() || height.is_some()) { return Err("page_size conflicts with width/height".into()); }
@@ -1524,11 +1532,13 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1524
1532
  if [width.as_ref(), height.as_ref()].into_iter().flatten().any(|value| value.0 <= 0 || value.0 > 31680) { return Err("page dimensions must be >0 and <=1584 points".into()); }
1525
1533
  if orientation.as_ref().is_some_and(|v| !matches!(v.as_str(), "portrait" | "landscape")) { return Err("orientation must be portrait/landscape".into()); }
1526
1534
  if columns.is_some_and(|v| v == 0 || v > 45) { return Err("columns must be between 1 and 45".into()); }
1527
- if column_widths.as_ref().is_some_and(|v| v.is_empty() || v.len() > 45 || v.iter().any(|p| p.0 <= 0)) { return Err("column_widths requires 1..45 positive widths".into()); }
1535
+ if column_spacing.is_some_and(|p| !(0..=31680).contains(&p.0)) { return Err("column_spacing must be between 0 and 1584 points".into()); }
1536
+ if column_widths.as_ref().is_some_and(|v| v.is_empty() || v.len() > 45 || v.iter().any(|p| p.0 <= 0 || p.0 > 31680)) { return Err("column_widths requires 1..45 positive widths of at most 1584 points".into()); }
1528
1537
  if let (Some(count), Some(widths)) = (columns.as_ref(), column_widths.as_ref()) { if *count as usize != widths.len() { return Err("columns must match column_widths length".into()); } }
1529
1538
  if break_type.as_ref().is_some_and(|v| !matches!(v.as_str(), "nextPage" | "continuous" | "oddPage" | "evenPage" | "nextColumn")) { return Err("unsupported section break_type".into()); }
1530
1539
  if page_number_format.as_ref().is_some_and(|v| !matches!(v.as_str(), "decimal" | "upperRoman" | "lowerRoman" | "upperLetter" | "lowerLetter")) { return Err("unsupported page_number_format".into()); }
1531
- if page_number_start == &Some(0) { return Err("page_number_start must be positive".into()); }
1540
+ if page_number_continue == &Some(false) { return Err("page_number_continue only accepts true; use page_number_start to restart".into()); }
1541
+ if page_number_start.is_some_and(|value| value == 0 || value > i32::MAX as u32) { return Err("page_number_start must be between 1 and 2147483647".into()); }
1532
1542
  if page_number_start.is_some() && page_number_continue == &Some(true) { return Err("page_number_start conflicts with page_number_continue".into()); }
1533
1543
  if page_size.is_none() && width.is_none() && orientation.is_none() && columns.is_none() && column_spacing.is_none() && column_widths.is_none() && column_separator.is_none() && break_type.is_none() && page_number_format.is_none() && page_number_start.is_none() && page_number_continue.is_none() { return Err("set_section_layout requires a property".into()); }
1534
1544
  }
@@ -1833,6 +1843,7 @@ fn fold_typed(
1833
1843
  let resolved = match resolve_typed_aliases(op, &aliases) {
1834
1844
  Ok(value) => value,
1835
1845
  Err(error) => {
1846
+ attach_affected(&mut state, &mut reports, &affected_ids);
1836
1847
  let report = PlanReport {
1837
1848
  completed: index,
1838
1849
  stopped_at: Some(index),
@@ -1872,7 +1883,7 @@ fn fold_typed(
1872
1883
  report.summary.push_str(&note);
1873
1884
  }
1874
1885
  }
1875
- attach_affected(&state, &mut reports, &affected_ids);
1886
+ attach_affected(&mut state, &mut reports, &affected_ids);
1876
1887
  let report = PlanReport {
1877
1888
  completed: index,
1878
1889
  stopped_at: Some(index),
@@ -1892,10 +1903,11 @@ fn fold_typed(
1892
1903
  );
1893
1904
  }
1894
1905
  if let EditOp::InsertParagraph { alias: Some(alias), .. }
1895
- | EditOp::InsertTable { alias: Some(alias), .. } = op
1906
+ | EditOp::InsertTable { alias: Some(alias), .. }
1907
+ | EditOp::EditTable { action: TableEdit::InsertRows { alias: Some(alias), .. } | TableEdit::InsertColumns { alias: Some(alias), .. }, .. } = op
1896
1908
  {
1897
1909
  if aliases.contains_key(alias) {
1898
- attach_affected(&state, &mut reports, &affected_ids);
1910
+ attach_affected(&mut state, &mut reports, &affected_ids);
1899
1911
  let report = PlanReport {
1900
1912
  completed: index,
1901
1913
  stopped_at: Some(index),
@@ -1917,7 +1929,7 @@ fn fold_typed(
1917
1929
  )),
1918
1930
  );
1919
1931
  }
1920
- if let EditOp::InsertTable { .. } = op {
1932
+ if matches!(op, EditOp::InsertTable { .. } | EditOp::EditTable { .. }) {
1921
1933
  aliases.insert(alias.clone(), Value::String(format!("${alias}")));
1922
1934
  }
1923
1935
  if let Some(id) = outcome
@@ -1931,7 +1943,7 @@ fn fold_typed(
1931
1943
  }
1932
1944
  }
1933
1945
  }
1934
- attach_affected(&state, &mut reports, &affected_ids);
1946
+ attach_affected(&mut state, &mut reports, &affected_ids);
1935
1947
  let report = PlanReport {
1936
1948
  completed: plan.ops.len(),
1937
1949
  stopped_at: None,
@@ -2013,10 +2025,11 @@ fn explain_selector_drift(
2013
2025
  /// Fill each operation report with the rendered markup of the paragraphs it
2014
2026
  /// affected, as they stand in `state`, from one render of the document.
2015
2027
  fn attach_affected(
2016
- state: &crate::commands::WorkState,
2028
+ state: &mut crate::commands::WorkState,
2017
2029
  reports: &mut [OperationReport],
2018
2030
  affected_ids: &[Vec<String>],
2019
2031
  ) {
2032
+ state.flush_lists();
2020
2033
  let wanted = affected_ids.iter().flatten().cloned().collect();
2021
2034
  let markup = crate::commands::affected_markup(state, &wanted);
2022
2035
  for (report, ids) in reports.iter_mut().zip(affected_ids) {
@@ -2058,6 +2071,7 @@ fn operation_name(op: &EditOp) -> &'static str {
2058
2071
  EditOp::InsertParagraph { .. } => "insert_paragraph",
2059
2072
  EditOp::DeleteParagraphs { .. } => "delete_paragraphs",
2060
2073
  EditOp::SetPageMargins { .. } => "set_page_margins",
2074
+ EditOp::SetSectionBoundary { .. } => "set_section_boundary",
2061
2075
  EditOp::SetSectionLayout { .. } => "set_section_layout",
2062
2076
  EditOp::SetFirstPageMode { .. } => "set_first_page_mode",
2063
2077
  EditOp::SetEvenAndOddHeaders { .. } => "set_even_and_odd_headers",
@@ -2097,6 +2111,7 @@ fn resolve_typed_aliases(
2097
2111
  | EditOp::ReplaceParagraph { at, .. }
2098
2112
  | EditOp::FormatText { at, .. }
2099
2113
  | EditOp::InsertTable { at, .. }
2114
+ | EditOp::SetSectionBoundary { at, .. }
2100
2115
  | EditOp::FormatParagraph { at, .. }
2101
2116
  | EditOp::CommentAdd { at, .. } => resolve(at)?,
2102
2117
  EditOp::ReviseText { at: Some(at), .. } => resolve(at)?,
@@ -2110,6 +2125,12 @@ fn resolve_typed_aliases(
2110
2125
  resolve(address)?;
2111
2126
  }
2112
2127
  }
2128
+ EditOp::SetSectionLayout { section_handle:Some(handle),.. } => {
2129
+ if let Some(alias)=handle.strip_prefix("section:$") {
2130
+ let Some(Value::String(id))=aliases.get(alias) else { return Err(diag("unknown_alias",format!("unknown section paragraph alias ${alias}"),None)); };
2131
+ *handle=format!("section:{id}");
2132
+ }
2133
+ }
2113
2134
  _ => {}
2114
2135
  }
2115
2136
  Ok(resolved)
@@ -2305,6 +2326,7 @@ fn typed_read(input: Option<&[u8]>, kind: &str, view: Option<&str>) -> crate::ou
2305
2326
  map.insert("markup".into(), serde_json::json!(markup));
2306
2327
  map.insert("css".into(), serde_json::json!(css));
2307
2328
  map.insert("tables".into(), crate::commands::tables::inventory(&state.xml));
2329
+ map.insert("sections".into(), crate::commands::sections::inventory(&state.package,&state.xml,&paragraph_ids,view==Some("original")));
2308
2330
  map.insert("styles".into(), serde_json::json!(crate::html::styles::Styles::load(&state.package).catalog()));
2309
2331
  if !equations.is_empty() {
2310
2332
  map.insert("equations".into(), serde_json::json!(equations));
@@ -2379,6 +2401,7 @@ fn typed_read(input: Option<&[u8]>, kind: &str, view: Option<&str>) -> crate::ou
2379
2401
  }).collect();
2380
2402
  let mut result = result;
2381
2403
  if let Some(map) = result.result.as_mut().and_then(Value::as_object_mut) {
2404
+ map.insert("sections".into(),crate::commands::sections::inventory(&state.package,&state.xml,&paragraph_ids,view==Some("original")));
2382
2405
  if let Some(Value::Array(paragraphs)) = map.get("paragraphs") {
2383
2406
  let equations: Vec<Value> = paragraphs
2384
2407
  .iter()
@@ -2409,6 +2432,7 @@ fn typed_read(input: Option<&[u8]>, kind: &str, view: Option<&str>) -> crate::ou
2409
2432
  serde_json::json!(crate::html::assets::manifest(&state.package, false)),
2410
2433
  );
2411
2434
  map.insert("tables".into(), crate::commands::tables::inventory(&state.xml));
2435
+ map.insert("sections".into(), crate::commands::sections::inventory(&state.package,&state.xml,&paragraph_ids,view==Some("original")));
2412
2436
  map.insert("html".into(), Value::String(rendered.html));
2413
2437
  }
2414
2438
  if let Some(map) = result.result.as_mut().and_then(Value::as_object_mut) {
@@ -346,19 +346,22 @@ pub fn set_section_layout(
346
346
  if page_size.is_some() || width.is_some() || orientation.is_some() {
347
347
  if (orient == "landscape" && w < h) || (orient == "portrait" && w > h) { std::mem::swap(&mut w, &mut h); }
348
348
  }
349
- let area = w - dimension("pgMar", "left", 1440) - dimension("pgMar", "right", 1440) - dimension("pgMar", "gutter", 0);
349
+ let area = w.saturating_sub(dimension("pgMar", "left", 1440)).saturating_sub(dimension("pgMar", "right", 1440)).saturating_sub(dimension("pgMar", "gutter", 0));
350
350
  let cols = xml.child_w(sect, "cols");
351
351
  let old_widths: Vec<i64> = cols.map(|n| xml.doc.children(n).filter(|&n| xml.is_w(n, "col")).filter_map(|n| xml.attr(n, "w").and_then(|v| v.parse().ok())).collect()).unwrap_or_default();
352
352
  let count = columns.map(|v| v as i64).or_else(|| column_widths.as_ref().map(|v| v.len() as i64)).unwrap_or_else(|| dimension("cols", "num", if old_widths.is_empty() { 1 } else { old_widths.len() as i64 }));
353
353
  let gap = column_spacing.map(|p| p.0).unwrap_or_else(|| dimension("cols", "space", 720));
354
+ let original_gaps = cols.map(|n| xml.doc.children(n).filter(|&n| xml.is_w(n, "col")).collect::<Vec<_>>()).unwrap_or_default();
355
+ let inherited_gap_total = original_gaps.iter().take(original_gaps.len().saturating_sub(1)).fold(0_i64, |total, &n| total.saturating_add(xml.attr(n, "space").and_then(|v| v.parse::<i64>().ok()).unwrap_or(gap)));
356
+ let gap_total = if column_spacing.is_none() && column_widths.is_none() && columns.is_none() && !old_widths.is_empty() { inherited_gap_total } else { gap.saturating_mul(count.saturating_sub(1)) };
354
357
  let unequal: Vec<i64> = column_widths.as_ref().map(|v| v.iter().map(|p| p.0).collect()).unwrap_or_else(|| old_widths.clone());
355
358
  let changes_columns = columns.is_some() || column_spacing.is_some() || column_widths.is_some() || column_separator.is_some();
356
359
  if changes_columns || width.is_some() || orientation.is_some() || page_size.is_some() {
357
- if area <= 0 || gap.saturating_mul(count - 1) >= area { return Err(Outcome::blocked("columns do not fit the section text area")); }
358
- if !unequal.is_empty() && columns.is_none() && (unequal.iter().sum::<i64>() + gap * (count - 1) > area) {
360
+ if area <= 0 || gap_total >= area { return Err(Outcome::blocked("columns do not fit the section text area")); }
361
+ if !unequal.is_empty() && columns.is_none() && (unequal.iter().fold(0_i64, |total, width| total.saturating_add(*width)).saturating_add(gap_total) > area) {
359
362
  return Err(Outcome::blocked("column widths and spacing exceed the section text area"));
360
363
  }
361
- if let Some(widths) = column_widths { if widths.iter().map(|p| p.0).sum::<i64>() + gap * (count - 1) > area { return Err(Outcome::blocked("column widths and spacing exceed the section text area")); } }
364
+ if let Some(widths) = column_widths { if widths.iter().fold(0_i64, |total, p| total.saturating_add(p.0)).saturating_add(gap_total) > area { return Err(Outcome::blocked("column widths and spacing exceed the section text area")); } }
362
365
  }
363
366
  if tracked { snapshot_section_properties(xml, sect, author, ctx)?; }
364
367
  if page_size.is_some() || width.is_some() || orientation.is_some() {
@@ -375,6 +378,10 @@ pub fn set_section_layout(
375
378
  for child in xml.doc.children(node).collect::<Vec<_>>() { if xml.is_w(child, "col") { xml.doc.remove_node(child); } }
376
379
  set_attr(xml, node, "w:equalWidth", if column_widths.is_some() { "0" } else { "1" });
377
380
  }
381
+ if column_spacing.is_some() && column_widths.is_none() && columns.is_none() {
382
+ let children: Vec<_> = xml.doc.children(node).filter(|&n| xml.is_w(n, "col")).collect();
383
+ for (index, &col) in children.iter().enumerate() { set_twips_attr(xml, col, "space", if index + 1 == children.len() { 0 } else { gap }); }
384
+ }
378
385
  if let Some(widths) = column_widths {
379
386
  for (index, width) in widths.iter().enumerate() {
380
387
  let col = xml.doc.create_element("w:col");