docxdriver 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {docxdriver-0.4.0 → docxdriver-0.4.2}/Cargo.lock +16 -6
  2. {docxdriver-0.4.0 → docxdriver-0.4.2}/Cargo.toml +1 -1
  3. {docxdriver-0.4.0 → docxdriver-0.4.2}/PKG-INFO +1 -1
  4. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/Cargo.toml +4 -3
  5. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/api.rs +16 -1
  6. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/comments.rs +33 -10
  7. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/equations.rs +4 -2
  8. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/headers.rs +8 -4
  9. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/layout.rs +14 -4
  10. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/mod.rs +24 -2
  11. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/readonly.rs +4 -1
  12. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/revisions.rs +145 -17
  13. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/tables.rs +16 -6
  14. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/typed.rs +172 -69
  15. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/document.rs +32 -22
  16. docxdriver-0.4.2/crates/docxdriver-core/src/integrity/mod.rs +1519 -0
  17. docxdriver-0.4.2/crates/docxdriver-core/src/integrity/schema.rs +512 -0
  18. docxdriver-0.4.2/crates/docxdriver-core/src/integrity/tree.rs +503 -0
  19. docxdriver-0.4.2/crates/docxdriver-core/src/integrity/wml-model.json +1 -0
  20. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/lib.rs +1 -0
  21. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/package.rs +54 -18
  22. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/segments.rs +338 -0
  23. {docxdriver-0.4.0 → docxdriver-0.4.2}/README.md +0 -0
  24. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/LICENSE +0 -0
  25. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/README.md +0 -0
  26. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/assets/blank.docx +0 -0
  27. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/lists.rs +0 -0
  28. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/reading.rs +0 -0
  29. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/sections.rs +0 -0
  30. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/table_structure.rs +0 -0
  31. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/facade.rs +0 -0
  32. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/assets.rs +0 -0
  33. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/build.rs +0 -0
  34. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/chrome.rs +0 -0
  35. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/compact.rs +0 -0
  36. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/ctx.rs +0 -0
  37. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/input.rs +0 -0
  38. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/mathml_omml.rs +0 -0
  39. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/mod.rs +0 -0
  40. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/omml_latex.rs +0 -0
  41. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/omml_mathml.rs +0 -0
  42. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/parse.rs +0 -0
  43. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/render.rs +0 -0
  44. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/styles.rs +0 -0
  45. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/surface.rs +0 -0
  46. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/media.rs +0 -0
  47. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/outcome.rs +0 -0
  48. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/.gitignore +0 -0
  49. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/Cargo.toml +0 -0
  50. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/README.md +0 -0
  51. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/python/docxdriver/__init__.py +0 -0
  52. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/python/docxdriver/document.py +0 -0
  53. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/python/docxdriver/plan.py +0 -0
  54. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/src/lib.rs +0 -0
  55. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/tests/test_document.py +0 -0
  56. {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/tests/test_plan.py +0 -0
  57. {docxdriver-0.4.0 → docxdriver-0.4.2}/pyproject.toml +0 -0
  58. {docxdriver-0.4.0 → docxdriver-0.4.2}/python/docxdriver/__init__.py +0 -0
  59. {docxdriver-0.4.0 → docxdriver-0.4.2}/python/docxdriver/document.py +0 -0
  60. {docxdriver-0.4.0 → docxdriver-0.4.2}/python/docxdriver/plan.py +0 -0
@@ -416,7 +416,7 @@ dependencies = [
416
416
 
417
417
  [[package]]
418
418
  name = "docxdriver-cli"
419
- version = "0.4.0"
419
+ version = "0.4.2"
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.4.0"
430
+ version = "0.4.2"
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.4.0"
448
+ version = "0.4.2"
448
449
  dependencies = [
449
450
  "axum",
450
451
  "cap-std",
@@ -462,14 +463,14 @@ dependencies = [
462
463
 
463
464
  [[package]]
464
465
  name = "docxdriver-monty-repl"
465
- version = "0.4.0"
466
+ version = "0.4.2"
466
467
  dependencies = [
467
468
  "docxdriver-core",
468
469
  ]
469
470
 
470
471
  [[package]]
471
472
  name = "docxdriver-python"
472
- version = "0.4.0"
473
+ version = "0.4.2"
473
474
  dependencies = [
474
475
  "docxdriver-core",
475
476
  "pyo3",
@@ -479,7 +480,7 @@ dependencies = [
479
480
 
480
481
  [[package]]
481
482
  name = "docxdriver-wasm"
482
- version = "0.4.0"
483
+ version = "0.4.2"
483
484
  dependencies = [
484
485
  "docxdriver-core",
485
486
  "js-sys",
@@ -1188,6 +1189,15 @@ dependencies = [
1188
1189
  "syn 2.0.118",
1189
1190
  ]
1190
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
+
1191
1201
  [[package]]
1192
1202
  name = "quote"
1193
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.4.0"
6
+ version = "0.4.2"
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.4.0
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for the native Rust DOCX engine
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -8,9 +8,9 @@ repository.workspace = true
8
8
  readme = "README.md"
9
9
  keywords = ["docx", "ooxml", "word", "document", "track-changes"]
10
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"]
11
+ # Library sources, the runtime template and the generated schema model
12
+ # only; tests, examples and the schema generator stay in the repository.
13
+ include = ["/src/**/*.rs", "!/src/bin/**", "/src/integrity/wml-model.json", "/assets/blank.docx", "/README.md", "/LICENSE"]
14
14
 
15
15
  [dependencies]
16
16
  xmloxide = { version = "0.4.3", default-features = false }
@@ -24,5 +24,6 @@ sha2 = "0.10"
24
24
  hex = "0.4"
25
25
  toml = "0.8"
26
26
  schemars = "0.8"
27
+ quick-xml = "0.42.0"
27
28
 
28
29
  [dev-dependencies]
@@ -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
  }
@@ -8,7 +8,7 @@ use xmloxide::tree::NodeId;
8
8
  use crate::document::DocxXml;
9
9
  use crate::outcome::Outcome;
10
10
  use crate::package::Package;
11
- use crate::segments::{all_text, build_segments, rename_for};
11
+ use crate::segments::{all_text, build_segments, mark_deleted_leaves, remove_content};
12
12
 
13
13
  use super::readonly::plural;
14
14
 
@@ -357,16 +357,25 @@ fn resolve_entry(xml: &mut DocxXml, entry: &RevisionEntry, accept: bool) -> Resu
357
357
  }
358
358
  sibling
359
359
  };
360
+ // The joined paragraph ends at the next paragraph's mark, so that
361
+ // mark's pPr and identity win, as in Word: this paragraph's
362
+ // content moves to the front of the next one and this one goes.
363
+ // <p A>Mine.¶(ins)</p><p B, list>item one¶</p>
364
+ // -> <p B, list>Mine.item one¶</p>
360
365
  if let Some(next) = next {
361
- let children: Vec<NodeId> = xml.doc.children(next).collect();
366
+ let anchor = xml.doc.children(next).find(|&c| !xml.is_w(c, "pPr"));
367
+ let children: Vec<NodeId> = xml.doc.children(entry.paragraph).collect();
362
368
  for child in children {
363
369
  if xml.is_w(child, "pPr") {
364
370
  continue;
365
371
  }
366
372
  xml.doc.detach(child);
367
- xml.doc.append_child(entry.paragraph, child);
373
+ match anchor {
374
+ Some(anchor) => xml.doc.insert_before(anchor, child),
375
+ None => xml.doc.append_child(next, child),
376
+ }
368
377
  }
369
- xml.doc.remove_node(next);
378
+ xml.doc.remove_node(entry.paragraph);
370
379
  }
371
380
  }
372
381
  return Ok(());
@@ -381,24 +390,15 @@ fn resolve_entry(xml: &mut DocxXml, entry: &RevisionEntry, accept: bool) -> Resu
381
390
  xml.doc.detach(child);
382
391
  xml.doc.insert_before(entry.node, child);
383
392
  if !accept {
384
- // Rejected delete: its w:delText leaves become live w:t again.
385
- let leaves: Vec<NodeId> = xml
386
- .doc
387
- .descendants(child)
388
- .filter(|&n| xml.is_w(n, "delText"))
389
- .collect();
390
- for leaf in leaves {
391
- let name = rename_for(xml, leaf, false);
392
- xml.doc.rename_element(leaf, name);
393
- }
393
+ // Rejected delete: its w:delText leaves become live w:t
394
+ // again, and w:delInstrText field codes w:instrText.
395
+ mark_deleted_leaves(xml, child, false);
394
396
  }
395
397
  }
396
398
  xml.doc.remove_node(entry.node);
397
399
  }
398
400
  // Rejected insert / accepted delete: the content goes.
399
- (true, false) | (false, true) => {
400
- xml.doc.remove_node(entry.node);
401
- }
401
+ (true, false) | (false, true) => remove_content(xml, entry.node),
402
402
  }
403
403
  Ok(())
404
404
  }
@@ -519,6 +519,27 @@ fn accept_reject_all_in_xml(xml: &mut DocxXml, accept: bool) -> Result<usize, Ou
519
519
  resolve_entry(xml, &entry, accept)?;
520
520
  count += 1;
521
521
  }
522
+ // Tracked cell insertions and deletions: the marker sits in the cell's
523
+ // tcPr, and the cell goes on a rejected insert or an accepted delete.
524
+ let mut shortened: Vec<NodeId> = Vec::new();
525
+ loop {
526
+ let marker = scope(xml).and_then(|root| {
527
+ xml.doc.descendants(root).find(|&n| {
528
+ (xml.is_w(n, "cellIns") || xml.is_w(n, "cellDel"))
529
+ && xml.doc.parent(n).is_some_and(|p| xml.is_w(p, "tcPr"))
530
+ })
531
+ });
532
+ let Some(marker) = marker else { break };
533
+ count += 1;
534
+ let cell = xml.doc.parent(marker).and_then(|p| xml.doc.parent(p));
535
+ if accept != xml.is_w(marker, "cellDel") {
536
+ xml.doc.remove_node(marker);
537
+ continue;
538
+ }
539
+ if let Some(row) = cell.and_then(|cell| remove_cell(xml, cell)) {
540
+ shortened.push(row);
541
+ }
542
+ }
522
543
  // One record at a time, re-scanning between resolutions: rejecting a
523
544
  // record swaps subtrees around, which can relocate or remove records
524
545
  // collected earlier (e.g. an rPrChange nested in a pPrChange snapshot).
@@ -534,12 +555,119 @@ fn accept_reject_all_in_xml(xml: &mut DocxXml, accept: bool) -> Result<usize, Ou
534
555
  resolve_property_change(xml, node, accept);
535
556
  count += 1;
536
557
  }
558
+ // Rows that lost a cell are padded with gridAfter against the settled
559
+ // grid, the way Word writes a short row.
560
+ for row in shortened {
561
+ pad_short_row(xml, row);
562
+ }
537
563
  if let Some(root) = scope(xml) {
538
564
  strip_move_range_markers_in(xml, root, None);
539
565
  }
540
566
  Ok(count)
541
567
  }
542
568
 
569
+ /// The cells of `row`, through any content-control wrappers, but not those
570
+ /// of tables nested in its cells.
571
+ fn row_cells(xml: &DocxXml, row: NodeId) -> Vec<NodeId> {
572
+ xml.doc
573
+ .descendants(row)
574
+ .filter(|&n| {
575
+ xml.is_w(n, "tc")
576
+ && xml.doc.ancestors(n).skip(1).find(|&a| xml.is_w(a, "tr")) == Some(row)
577
+ })
578
+ .collect()
579
+ }
580
+
581
+ /// Remove a table cell. A row left without cells goes too, and a table
582
+ /// left without rows becomes an empty paragraph. Returns the row when it
583
+ /// remains, now short of its grid.
584
+ fn remove_cell(xml: &mut DocxXml, cell: NodeId) -> Option<NodeId> {
585
+ let row = xml.doc.ancestors(cell).skip(1).find(|&a| xml.is_w(a, "tr"))?;
586
+ // A content control holding only this cell goes with it.
587
+ let mut target = cell;
588
+ while let Some(parent) = xml.doc.parent(target) {
589
+ let wrapper = xml.is_w(parent, "sdtContent")
590
+ && xml.doc.children(parent).filter(|&c| xml.doc.is_element(c)).count() == 1;
591
+ if !wrapper {
592
+ break;
593
+ }
594
+ target = xml.doc.parent(parent).unwrap_or(parent);
595
+ }
596
+ xml.doc.remove_node(target);
597
+ if !row_cells(xml, row).is_empty() {
598
+ return Some(row);
599
+ }
600
+ let table = xml.doc.ancestors(row).skip(1).find(|&a| xml.is_w(a, "tbl"));
601
+ xml.doc.remove_node(row);
602
+ if let Some(table) = table {
603
+ let rows = xml
604
+ .doc
605
+ .descendants(table)
606
+ .any(|n| xml.is_w(n, "tr") && xml.doc.ancestors(n).skip(1).find(|&a| xml.is_w(a, "tbl")) == Some(table));
607
+ if !rows {
608
+ let paragraph = xml.doc.create_element("w:p");
609
+ xml.doc.insert_before(table, paragraph);
610
+ xml.doc.remove_node(table);
611
+ }
612
+ }
613
+ None
614
+ }
615
+
616
+ /// Raise a row's gridAfter so its cells and padding cover the table grid.
617
+ fn pad_short_row(xml: &mut DocxXml, row: NodeId) {
618
+ let attached = xml.doc.root_element().is_some_and(|root| xml.doc.ancestors(row).any(|a| a == root));
619
+ let Some(table) = xml.doc.ancestors(row).skip(1).find(|&a| xml.is_w(a, "tbl")) else {
620
+ return;
621
+ };
622
+ if !attached {
623
+ return;
624
+ }
625
+ let columns = xml
626
+ .child_w(table, "tblGrid")
627
+ .map(|grid| xml.doc.children(grid).filter(|&c| xml.is_w(c, "gridCol")).count())
628
+ .unwrap_or(0) as i64;
629
+ let value = |xml: &DocxXml, parent: Option<NodeId>, name: &str, default: i64| {
630
+ parent
631
+ .and_then(|p| xml.child_w(p, name))
632
+ .and_then(|n| xml.attr(n, "val"))
633
+ .and_then(|v| v.trim().parse::<i64>().ok())
634
+ .unwrap_or(default)
635
+ };
636
+ let trpr = xml.child_w(row, "trPr");
637
+ let after = value(xml, trpr, "gridAfter", 0);
638
+ let mut position = value(xml, trpr, "gridBefore", 0) + after;
639
+ for cell in row_cells(xml, row) {
640
+ position += value(xml, xml.child_w(cell, "tcPr"), "gridSpan", 1);
641
+ }
642
+ if columns == 0 || position >= columns {
643
+ return;
644
+ }
645
+ let trpr = match trpr {
646
+ Some(trpr) => trpr,
647
+ None => {
648
+ let trpr = xml.doc.create_element("w:trPr");
649
+ match xml.child_w(row, "tblPrEx") {
650
+ Some(ex) => xml.doc.insert_after(ex, trpr),
651
+ None => match xml.doc.first_child(row) {
652
+ Some(first) => xml.doc.insert_before(first, trpr),
653
+ None => xml.doc.append_child(row, trpr),
654
+ },
655
+ }
656
+ trpr
657
+ }
658
+ };
659
+ if let Some(old) = xml.child_w(trpr, "gridAfter") {
660
+ xml.doc.remove_node(old);
661
+ }
662
+ let grid_after = xml.doc.create_element("w:gridAfter");
663
+ xml.doc.set_attribute(grid_after, "w:val", &(after + columns - position).to_string());
664
+ // CT_TrPrBase is an unordered choice; the revision records close CT_TrPr.
665
+ match xml.doc.first_child(trpr) {
666
+ Some(first) => xml.doc.insert_before(first, grid_after),
667
+ None => xml.doc.append_child(trpr, grid_after),
668
+ }
669
+ }
670
+
543
671
  /* split_boundary(xml, paragraph, offset);
544
672
  let segments = build_segments(xml, paragraph);
545
673
 
@@ -363,13 +363,23 @@ pub fn validate_row(at: &str, f: &RowFormat) -> Result<(), String> {
363
363
  }
364
364
  Ok(())
365
365
  }
366
+ /// The properties element of a table, row or cell. It precedes all content —
367
+ /// including cells wrapped in w:sdt or w:customXml, which `ensure` would not
368
+ /// recognize — and follows only w:tblPrEx in a row.
366
369
  fn bag(x: &mut DocxXml, n: NodeId, tag: &str) -> NodeId {
367
- let seq = match tag {
368
- "tblPr" => &["tblPr", "tblGrid", "tr"][..],
369
- "trPr" => &["tblPrEx", "trPr", "tc"][..],
370
- _ => &["tcPr", "p", "tbl"][..],
371
- };
372
- ensure(x, n, tag, seq)
370
+ if let Some(existing) = x.child_w(n, tag) {
371
+ return existing;
372
+ }
373
+ let props = x.doc.create_element(&format!("w:{tag}"));
374
+ let next = x
375
+ .doc
376
+ .children(n)
377
+ .find(|&c| x.doc.is_element(c) && !(tag == "trPr" && x.is_w(c, "tblPrEx")));
378
+ match next {
379
+ Some(next) => x.doc.insert_before(next, props),
380
+ None => x.doc.append_child(n, props),
381
+ }
382
+ props
373
383
  }
374
384
  fn snapshot(
375
385
  x: &mut DocxXml,