docxdriver 0.2.7__tar.gz → 0.3.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 (68) hide show
  1. {docxdriver-0.2.7 → docxdriver-0.3.0}/Cargo.lock +5 -5
  2. {docxdriver-0.2.7 → docxdriver-0.3.0}/Cargo.toml +1 -1
  3. {docxdriver-0.2.7 → docxdriver-0.3.0}/PKG-INFO +1 -1
  4. docxdriver-0.3.0/crates/docxdriver-core/assets/blank.docx +0 -0
  5. docxdriver-0.3.0/crates/docxdriver-core/examples/plan_scaling.rs +43 -0
  6. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/api.rs +164 -21
  7. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/bin/generate_typed_schema.rs +2 -0
  8. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/headers.rs +53 -3
  9. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/layout.rs +65 -38
  10. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/mod.rs +105 -56
  11. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/typed.rs +416 -37
  12. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/document.rs +51 -8
  13. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/fixtures/projection-parity/parity.docx +0 -0
  14. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/fixtures/projection-parity/prior-projection.json +190 -190
  15. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/typed_api.rs +328 -1
  16. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/python/docxdriver/repl/prelude.py +2 -2
  17. {docxdriver-0.2.7 → docxdriver-0.3.0/crates/docxdriver-python}/python/docxdriver/repl/quotes.py +22 -15
  18. {docxdriver-0.2.7 → docxdriver-0.3.0}/python/docxdriver/repl/prelude.py +2 -2
  19. {docxdriver-0.2.7/crates/docxdriver-python → docxdriver-0.3.0}/python/docxdriver/repl/quotes.py +22 -15
  20. docxdriver-0.2.7/crates/docxdriver-core/assets/blank.docx +0 -0
  21. {docxdriver-0.2.7 → docxdriver-0.3.0}/README.md +0 -0
  22. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/Cargo.toml +0 -0
  23. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/README.md +0 -0
  24. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/comments.rs +0 -0
  25. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/equations.rs +0 -0
  26. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/reading.rs +0 -0
  27. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/readonly.rs +0 -0
  28. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/commands/revisions.rs +0 -0
  29. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/README.md +0 -0
  30. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/assets.rs +0 -0
  31. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/build.rs +0 -0
  32. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/chrome.rs +0 -0
  33. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/compact.rs +0 -0
  34. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/ctx.rs +0 -0
  35. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/input.rs +0 -0
  36. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/mathml_omml.rs +0 -0
  37. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/mod.rs +0 -0
  38. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/omml_latex.rs +0 -0
  39. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/omml_mathml.rs +0 -0
  40. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/parse.rs +0 -0
  41. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/render.rs +0 -0
  42. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/styles.rs +0 -0
  43. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/html/surface.rs +0 -0
  44. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/lib.rs +0 -0
  45. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/media.rs +0 -0
  46. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/outcome.rs +0 -0
  47. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/package.rs +0 -0
  48. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/src/segments.rs +0 -0
  49. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/README.md +0 -0
  50. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/fixtures/projection-parity/features.json +0 -0
  51. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/fixtures/ts-reference.docx +0 -0
  52. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/html_e2e.rs +0 -0
  53. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/interop.rs +0 -0
  54. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/package_layout.rs +0 -0
  55. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/projection_parity.rs +0 -0
  56. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-core/tests/spike.rs +0 -0
  57. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/.gitignore +0 -0
  58. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/Cargo.toml +0 -0
  59. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/README.md +0 -0
  60. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/python/docxdriver/__init__.py +0 -0
  61. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/python/docxdriver/repl/__init__.py +0 -0
  62. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/python/docxdriver/repl/host.py +0 -0
  63. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/src/lib.rs +0 -0
  64. {docxdriver-0.2.7 → docxdriver-0.3.0}/crates/docxdriver-python/tests/test_host.py +0 -0
  65. {docxdriver-0.2.7 → docxdriver-0.3.0}/pyproject.toml +0 -0
  66. {docxdriver-0.2.7 → docxdriver-0.3.0}/python/docxdriver/__init__.py +0 -0
  67. {docxdriver-0.2.7 → docxdriver-0.3.0}/python/docxdriver/repl/__init__.py +0 -0
  68. {docxdriver-0.2.7 → docxdriver-0.3.0}/python/docxdriver/repl/host.py +0 -0
@@ -416,7 +416,7 @@ dependencies = [
416
416
 
417
417
  [[package]]
418
418
  name = "docxdriver-cli"
419
- version = "0.2.7"
419
+ version = "0.3.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.2.7"
430
+ version = "0.3.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.2.7"
447
+ version = "0.3.0"
448
448
  dependencies = [
449
449
  "axum",
450
450
  "cap-std",
@@ -462,7 +462,7 @@ dependencies = [
462
462
 
463
463
  [[package]]
464
464
  name = "docxdriver-python"
465
- version = "0.2.7"
465
+ version = "0.3.0"
466
466
  dependencies = [
467
467
  "docxdriver-core",
468
468
  "pyo3",
@@ -471,7 +471,7 @@ dependencies = [
471
471
 
472
472
  [[package]]
473
473
  name = "docxdriver-wasm"
474
- version = "0.2.7"
474
+ version = "0.3.0"
475
475
  dependencies = [
476
476
  "docxdriver-core",
477
477
  "js-sys",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/docxdriver-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.7"
6
+ version = "0.3.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.2.7
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for the native Rust DOCX engine
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -0,0 +1,43 @@
1
+ //! Plan review scaling probe: `cargo run --release -p docxdriver-core --example plan_scaling -- PARAS OPS`.
2
+ use std::io::Write;
3
+ use std::time::Instant;
4
+
5
+ use docxdriver_core::{execute_plan, ChangeMode, EditOp, ParagraphAddress, Plan, PlanRequest, PlanResult, SourceHash};
6
+
7
+ fn package(body: &str) -> Vec<u8> {
8
+ let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
9
+ let o = zip::write::SimpleFileOptions::default();
10
+ let parts = [
11
+ ("[Content_Types].xml", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>".to_string()),
12
+ ("_rels/.rels", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/></Relationships>".to_string()),
13
+ ("word/document.xml", body.to_string()),
14
+ ("word/_rels/document.xml.rels", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>".to_string()),
15
+ ];
16
+ for (n, c) in parts {
17
+ w.start_file(n, o).unwrap();
18
+ w.write_all(c.as_bytes()).unwrap();
19
+ }
20
+ w.finish().unwrap().into_inner()
21
+ }
22
+
23
+ fn main() {
24
+ let args: Vec<usize> = std::env::args().skip(1).map(|a| a.parse().unwrap()).collect();
25
+ let (paras, ops) = (args.first().copied().unwrap_or(1500), args.get(1).copied().unwrap_or(300));
26
+ let body: String = (0..paras)
27
+ .map(|i| format!("<w:p w14:paraId=\"{:08X}\"><w:r><w:t>Paragraph {i} says the tenant shall pay rent within thirty days of notice.</w:t></w:r></w:p>", i + 1))
28
+ .collect();
29
+ let bytes = package(&format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:w14=\"http://schemas.microsoft.com/office/word/2010/wordml\"><w:body>{body}</w:body></w:document>"));
30
+ let operations = (0..ops)
31
+ .map(|i| EditOp::ReplaceText {
32
+ at: ParagraphAddress::id(format!("{:08X}", (i * paras / ops) + 1)),
33
+ select: "thirty days".into(),
34
+ with: "sixty days".into(),
35
+ occurrence: None,
36
+ })
37
+ .collect();
38
+ let plan = Plan { base: SourceHash::from_bytes(&bytes), author: "bench".into(), change_mode: ChangeMode::Track, ops: operations };
39
+ let t = Instant::now();
40
+ let result = execute_plan(&bytes, &PlanRequest { plan, preview_key: None });
41
+ let ok = matches!(result, PlanResult::Previewed { .. });
42
+ println!("paras={paras} ops={ops} previewed={ok} elapsed={:?}", t.elapsed());
43
+ }
@@ -342,6 +342,19 @@ pub enum EditOp {
342
342
  #[serde(default)]
343
343
  occurrence: Option<u32>,
344
344
  },
345
+ /// Several exact replacements inside one bounded scope: the paragraph
346
+ /// `at`, the unique `within` span (in `at`, or anywhere in the body when
347
+ /// `at` is omitted), or the whole body. Every edit resolves against the
348
+ /// scope as it stands when this operation starts.
349
+ ReviseText {
350
+ #[serde(default)]
351
+ at: Option<ParagraphAddress>,
352
+ #[serde(default)]
353
+ within: Option<String>,
354
+ #[serde(default)]
355
+ occurrence: Option<u32>,
356
+ edits: Vec<TextEdit>,
357
+ },
345
358
  ReplaceParagraph {
346
359
  at: ParagraphAddress,
347
360
  with: String,
@@ -474,6 +487,20 @@ pub enum EditOp {
474
487
  },
475
488
  }
476
489
 
490
+ /// One replacement inside a `revise_text` scope.
491
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
492
+ #[serde(deny_unknown_fields)]
493
+ pub struct TextEdit {
494
+ pub select: String,
495
+ pub with: String,
496
+ /// 1-based match within the scope; omit when `select` is unique there.
497
+ #[serde(default)]
498
+ pub occurrence: Option<u32>,
499
+ /// Replace every match within the scope.
500
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
501
+ pub all: bool,
502
+ }
503
+
477
504
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
478
505
  #[serde(deny_unknown_fields)]
479
506
  pub struct CreateCommand {
@@ -970,6 +997,10 @@ fn validation_field(message: &str) -> Option<&'static str> {
970
997
  "mathml"
971
998
  } else if message.contains("equation must") {
972
999
  "equation"
1000
+ } else if message.contains("edits") {
1001
+ "edits"
1002
+ } else if message.contains("within") {
1003
+ "within"
973
1004
  } else if message.contains("select") {
974
1005
  "select"
975
1006
  } else if message.contains("occurrence") {
@@ -1030,6 +1061,38 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1030
1061
  return Err("occurrence must be positive".into());
1031
1062
  }
1032
1063
  }
1064
+ EditOp::ReviseText {
1065
+ within,
1066
+ occurrence,
1067
+ edits,
1068
+ ..
1069
+ } => {
1070
+ if within.as_deref() == Some("") {
1071
+ return Err("within must be non-empty when given".into());
1072
+ }
1073
+ if occurrence == &Some(0) {
1074
+ return Err("occurrence must be positive".into());
1075
+ }
1076
+ if occurrence.is_some() && within.is_none() {
1077
+ return Err("occurrence picks a within match; it requires within".into());
1078
+ }
1079
+ if edits.is_empty() {
1080
+ return Err("edits must list at least one replacement".into());
1081
+ }
1082
+ for (index, edit) in edits.iter().enumerate() {
1083
+ if edit.select.is_empty() {
1084
+ return Err(format!("edits[{index}].select must be non-empty"));
1085
+ }
1086
+ if edit.occurrence == Some(0) {
1087
+ return Err(format!("edits[{index}].occurrence must be positive"));
1088
+ }
1089
+ if edit.all && edit.occurrence.is_some() {
1090
+ return Err(format!(
1091
+ "edits[{index}] sets both all and occurrence; choose one"
1092
+ ));
1093
+ }
1094
+ }
1095
+ }
1033
1096
  EditOp::ReplaceParagraph { .. } => {}
1034
1097
  EditOp::FormatText {
1035
1098
  select,
@@ -1431,6 +1494,8 @@ fn fold_typed(
1431
1494
  }
1432
1495
  let mut aliases: BTreeMap<String, Value> = BTreeMap::new();
1433
1496
  let mut reports = Vec::new();
1497
+ let mut affected_ids: Vec<Vec<String>> = Vec::new();
1498
+ let plan_seed = crate::commands::plan_seed(plan);
1434
1499
  let mut allocated_ids = Vec::new();
1435
1500
  let ctx = crate::commands::ExecCtx {
1436
1501
  dry_run: !commit,
@@ -1459,33 +1524,27 @@ fn fold_typed(
1459
1524
  plan.change_mode,
1460
1525
  index,
1461
1526
  &seed,
1462
- plan,
1527
+ &plan_seed,
1463
1528
  &ctx,
1464
1529
  );
1465
- let mut report_value = outcome.result_json();
1466
- report_value = crate::commands::attach_affected_markup(&state, report_value);
1530
+ let report_value = outcome.result_json();
1467
1531
  let applied = outcome.status == crate::outcome::Status::Ok;
1468
- let affected = report_value
1469
- .get("markup")
1470
- .and_then(Value::as_str)
1471
- .map(|markup| {
1472
- let para_id = report_value
1473
- .get("result")
1474
- .and_then(|r| r.get("paraId"))
1475
- .and_then(Value::as_str)
1476
- .unwrap_or("");
1477
- vec![serde_json::json!({"para_id": para_id, "markup": markup})]
1478
- })
1479
- .unwrap_or_default();
1532
+ affected_ids.push(crate::commands::affected_para_ids(&report_value));
1480
1533
  reports.push(OperationReport {
1481
1534
  index,
1482
1535
  op: name,
1483
1536
  outcome: if applied { "applied" } else { "rejected" }.into(),
1484
1537
  summary: outcome.summary.clone(),
1485
- affected,
1538
+ affected: Vec::new(),
1486
1539
  context_truncated: false,
1487
1540
  });
1488
1541
  if !applied {
1542
+ if let Some(note) = explain_selector_drift(input, plan, &resolved, index, &affected_ids) {
1543
+ if let Some(report) = reports.last_mut() {
1544
+ report.summary.push_str(&note);
1545
+ }
1546
+ }
1547
+ attach_affected(&state, &mut reports, &affected_ids);
1489
1548
  let report = PlanReport {
1490
1549
  completed: index,
1491
1550
  stopped_at: Some(index),
@@ -1494,15 +1553,12 @@ fn fold_typed(
1494
1553
  allocated_ids: allocated_ids.clone(),
1495
1554
  aliases: aliases.clone().into_iter().collect(),
1496
1555
  };
1556
+ let message = report.ops[index].summary.clone();
1497
1557
  return (
1498
1558
  report.clone(),
1499
1559
  None,
1500
1560
  Some((
1501
- diag(
1502
- "operation_rejected",
1503
- outcome.summary,
1504
- Some(format!("ops[{index}]")),
1505
- ),
1561
+ diag("operation_rejected", message, Some(format!("ops[{index}]"))),
1506
1562
  report,
1507
1563
  )),
1508
1564
  );
@@ -1512,6 +1568,7 @@ fn fold_typed(
1512
1568
  } = op
1513
1569
  {
1514
1570
  if aliases.contains_key(alias) {
1571
+ attach_affected(&state, &mut reports, &affected_ids);
1515
1572
  let report = PlanReport {
1516
1573
  completed: index,
1517
1574
  stopped_at: Some(index),
@@ -1544,6 +1601,7 @@ fn fold_typed(
1544
1601
  }
1545
1602
  }
1546
1603
  }
1604
+ attach_affected(&state, &mut reports, &affected_ids);
1547
1605
  let report = PlanReport {
1548
1606
  completed: plan.ops.len(),
1549
1607
  stopped_at: None,
@@ -1559,6 +1617,89 @@ fn fold_typed(
1559
1617
  );
1560
1618
  (report, out.bytes, None)
1561
1619
  }
1620
+ /// When a selector matched nothing but matches the same paragraph as it was
1621
+ /// before the plan, name the earlier operations that changed that text:
1622
+ /// selectors address the text left by preceding operations, not the read.
1623
+ fn explain_selector_drift(
1624
+ input: &[u8],
1625
+ plan: &Plan,
1626
+ op: &EditOp,
1627
+ index: usize,
1628
+ affected_ids: &[Vec<String>],
1629
+ ) -> Option<String> {
1630
+ let (at, selects): (&ParagraphAddress, Vec<&str>) = match op {
1631
+ EditOp::ReplaceText { at, select, .. } | EditOp::FormatText { at, select, .. } => {
1632
+ (at, vec![select.as_str()])
1633
+ }
1634
+ EditOp::CommentAdd {
1635
+ at,
1636
+ select: Some(select),
1637
+ ..
1638
+ } => (at, vec![select.as_str()]),
1639
+ EditOp::ReviseText {
1640
+ at: Some(at),
1641
+ within,
1642
+ edits,
1643
+ ..
1644
+ } => (
1645
+ at,
1646
+ within
1647
+ .iter()
1648
+ .map(String::as_str)
1649
+ .chain(edits.iter().map(|e| e.select.as_str()))
1650
+ .collect(),
1651
+ ),
1652
+ _ => return None,
1653
+ };
1654
+ if index == 0 {
1655
+ return None;
1656
+ }
1657
+ let ParagraphAddress::Id(id) = at else {
1658
+ return None;
1659
+ };
1660
+ let id = id.to_uppercase();
1661
+ let base = crate::commands::base_visible_text(input, &id)?;
1662
+ if !selects.iter().all(|select| base.contains(select)) {
1663
+ return None;
1664
+ }
1665
+ let earlier: Vec<String> = plan.ops[..index]
1666
+ .iter()
1667
+ .enumerate()
1668
+ .filter(|(i, earlier)| {
1669
+ matches!(earlier, EditOp::RevisionSettle { .. })
1670
+ || affected_ids[*i].iter().any(|touched| touched.eq_ignore_ascii_case(&id))
1671
+ })
1672
+ .map(|(i, earlier)| format!("ops[{i}] {}", operation_name(earlier)))
1673
+ .collect();
1674
+ if earlier.is_empty() {
1675
+ return None;
1676
+ }
1677
+ Some(format!(
1678
+ "; it matches this paragraph as it was before the plan, but {} changed the text first — selectors address the text left by preceding operations (combine edits to one paragraph in a single revise_text, or settle revisions in a separate plan and re-read)",
1679
+ earlier.join(", ")
1680
+ ))
1681
+ }
1682
+
1683
+ /// Fill each operation report with the rendered markup of the paragraphs it
1684
+ /// affected, as they stand in `state`, from one render of the document.
1685
+ fn attach_affected(
1686
+ state: &crate::commands::WorkState,
1687
+ reports: &mut [OperationReport],
1688
+ affected_ids: &[Vec<String>],
1689
+ ) {
1690
+ let wanted = affected_ids.iter().flatten().cloned().collect();
1691
+ let markup = crate::commands::affected_markup(state, &wanted);
1692
+ for (report, ids) in reports.iter_mut().zip(affected_ids) {
1693
+ report.affected = ids
1694
+ .iter()
1695
+ .filter_map(|id| {
1696
+ markup
1697
+ .get(id)
1698
+ .map(|m| serde_json::json!({"para_id": id, "markup": m}))
1699
+ })
1700
+ .collect();
1701
+ }
1702
+ }
1562
1703
  fn empty_report(_plan: &Plan) -> PlanReport {
1563
1704
  PlanReport {
1564
1705
  completed: 0,
@@ -1574,6 +1715,7 @@ fn operation_name(op: &EditOp) -> &'static str {
1574
1715
  EditOp::ReplaceEquation { .. } => "replace_equation",
1575
1716
  EditOp::DeleteEquation { .. } => "delete_equation",
1576
1717
  EditOp::ReplaceText { .. } => "replace_text",
1718
+ EditOp::ReviseText { .. } => "revise_text",
1577
1719
  EditOp::ReplaceParagraph { .. } => "replace_paragraph",
1578
1720
  EditOp::FormatText { .. } => "format_text",
1579
1721
  EditOp::FormatParagraph { .. } => "format_paragraph",
@@ -1618,6 +1760,7 @@ fn resolve_typed_aliases(
1618
1760
  | EditOp::FormatText { at, .. }
1619
1761
  | EditOp::FormatParagraph { at, .. }
1620
1762
  | EditOp::CommentAdd { at, .. } => resolve(at)?,
1763
+ EditOp::ReviseText { at: Some(at), .. } => resolve(at)?,
1621
1764
  EditOp::InsertParagraph { at, .. } => {
1622
1765
  if let InsertAnchor::Paragraph(addr) = at {
1623
1766
  resolve(addr)?;
@@ -96,6 +96,7 @@ export type EditOp =
96
96
  | { op: 'replace_equation'; at: ParagraphAddress; mathml: string; equation?: number; display?: boolean }
97
97
  | { op: 'delete_equation'; at: ParagraphAddress; equation?: number }
98
98
  | { op: 'replace_text'; at: ParagraphAddress; select: string; with: string; occurrence?: number }
99
+ | { op: 'revise_text'; at?: ParagraphAddress; within?: string; occurrence?: number; edits: [TextEdit, ...TextEdit[]] }
99
100
  | { op: 'replace_paragraph'; at: ParagraphAddress; with: string }
100
101
  | { op: 'format_text'; at: ParagraphAddress; select: string; occurrence?: number; bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean; superscript?: boolean; subscript?: boolean; color?: string; font_size?: Point; clear?: string[] }
101
102
  | { op: 'format_paragraph'; at: ParagraphAddress; style?: string; alignment?: 'left'|'center'|'right'|'justify'; indent_left?: Point; indent_right?: Point; space_before?: Point; space_after?: Point; line_spacing?: LineSpacing; clear?: string[] }
@@ -112,6 +113,7 @@ export type EditOp =
112
113
  | { op: 'comment_set_status'; comment_id: string; status: 'open'|'resolved' }
113
114
  | { op: 'comment_delete'; comment_id: string }
114
115
  | { op: 'revision_settle'; target: string; action: 'accept'|'reject' };
116
+ export type TextEdit = { select: string; with: string; occurrence?: number; all?: boolean };
115
117
  export type CreateCommand = { kind: 'create'; paragraphs?: string[]; html?: string };
116
118
  export type ReadCommand = { kind: 'read'; read_kind?: 'document'|'document_ui'|'styles'|'comments'|'revisions'|'assets'; view?: 'markup'|'final'|'original' };
117
119
  export type FindCommand = { kind: 'find'; query: string; ignore_case?: boolean; view?: 'markup'|'final'|'original' };
@@ -193,7 +193,23 @@ fn chrome_part_xml(scope: &str, inner: &str) -> String {
193
193
  fn ensure_title_pg(xml: &mut DocxXml, sect: NodeId) {
194
194
  if xml.child_w(sect, "titlePg").is_none() {
195
195
  let node = xml.doc.create_element("w:titlePg");
196
- xml.doc.append_child(sect, node);
196
+ // CT_SectPr is a sequence; titlePg precedes these.
197
+ const SUCCESSORS: &[&str] = &[
198
+ "textDirection",
199
+ "bidi",
200
+ "rtlGutter",
201
+ "docGrid",
202
+ "printerSettings",
203
+ "sectPrChange",
204
+ ];
205
+ let successor = xml
206
+ .doc
207
+ .children(sect)
208
+ .find(|&child| SUCCESSORS.iter().any(|local| xml.is_w(child, local)));
209
+ match successor {
210
+ Some(next) => xml.doc.insert_before(next, node),
211
+ None => xml.doc.append_child(sect, node),
212
+ }
197
213
  }
198
214
  }
199
215
 
@@ -251,7 +267,14 @@ fn set_chrome_typed(
251
267
  let ref_node = xml.doc.create_element(&format!("w:{ref_tag}"));
252
268
  xml.doc.set_attribute(ref_node, "w:type", kind_str);
253
269
  xml.doc.set_attribute(ref_node, "r:id", &rid);
254
- xml.doc.append_child(sect, ref_node);
270
+ // Header and footer references lead CT_SectPr: insert after the last one.
271
+ let successor = xml.doc.children(sect).find(|&child| {
272
+ !xml.is_w(child, "headerReference") && !xml.is_w(child, "footerReference")
273
+ });
274
+ match successor {
275
+ Some(next) => xml.doc.insert_before(next, ref_node),
276
+ None => xml.doc.append_child(sect, ref_node),
277
+ }
255
278
 
256
279
  Ok((
257
280
  format!("section {section} {kind_str} {scope} set"),
@@ -345,7 +368,34 @@ pub fn set_even_and_odd_headers_typed(
345
368
  match (even_odd, existing) {
346
369
  (true, None) => {
347
370
  let node = settings.doc.create_element("w:evenAndOddHeaders");
348
- settings.doc.append_child(root, node);
371
+ // CT_Settings is a sequence: insert before the first later element.
372
+ const SUCCESSORS: &[&str] = &[
373
+ "bookFoldRevPrinting", "bookFoldPrinting", "bookFoldPrintingSheets",
374
+ "drawingGridHorizontalSpacing", "drawingGridVerticalSpacing",
375
+ "displayHorizontalDrawingGridEvery", "displayVerticalDrawingGridEvery",
376
+ "doNotUseMarginsForDrawingGridOrigin", "drawingGridHorizontalOrigin",
377
+ "drawingGridVerticalOrigin", "doNotShadeFormData", "noPunctuationKerning",
378
+ "characterSpacingControl", "printTwoOnOne", "strictFirstAndLastChars",
379
+ "noLineBreaksAfter", "noLineBreaksBefore", "savePreviewPicture",
380
+ "doNotValidateAgainstSchema", "saveInvalidXml", "ignoreMixedContent",
381
+ "alwaysShowPlaceholderText", "doNotDemarcateInvalidXml", "saveXmlDataOnly",
382
+ "useXSLTWhenSaving", "saveThroughXslt", "showXMLTags",
383
+ "alwaysMergeEmptyNamespace", "updateFields", "hdrShapeDefaults", "footnotePr",
384
+ "endnotePr", "compat", "docVars", "rsids", "mathPr", "attachedSchema",
385
+ "themeFontLang", "clrSchemeMapping", "doNotIncludeSubdocsInStats",
386
+ "doNotAutoCompressPictures", "forceUpgrade", "captions", "readModeInkLockDown",
387
+ "smartTagType", "schemaLibrary", "shapeDefaults", "doNotEmbedSmartTags",
388
+ "decimalSymbol", "listSeparator",
389
+ ];
390
+ let successor = settings.doc.children(root).find(|&child| {
391
+ settings
392
+ .local_name(child)
393
+ .is_some_and(|local| SUCCESSORS.contains(&local))
394
+ });
395
+ match successor {
396
+ Some(next) => settings.doc.insert_before(next, node),
397
+ None => settings.doc.append_child(root, node),
398
+ }
349
399
  }
350
400
  (true, Some(node)) => {
351
401
  if settings
@@ -32,6 +32,19 @@ fn ensure_pg_mar(sect: NodeId, xml: &mut DocxXml) -> NodeId {
32
32
  return node;
33
33
  }
34
34
  let node = xml.doc.create_element("w:pgMar");
35
+ // CT_PageMar requires all seven attributes; start from Word's defaults
36
+ // (1in margins, 0.5in header/footer, no gutter) before applying values.
37
+ for (attr, twips) in [
38
+ ("top", 1440),
39
+ ("right", 1440),
40
+ ("bottom", 1440),
41
+ ("left", 1440),
42
+ ("header", 720),
43
+ ("footer", 720),
44
+ ("gutter", 0),
45
+ ] {
46
+ set_twips_attr(xml, node, attr, twips);
47
+ }
35
48
  if let Some(successor) = xml.doc.children(sect).find(|&child| {
36
49
  [
37
50
  "pgBorders",
@@ -42,6 +55,7 @@ fn ensure_pg_mar(sect: NodeId, xml: &mut DocxXml) -> NodeId {
42
55
  "formProtection",
43
56
  "vAlign",
44
57
  "noEndnote",
58
+ "titlePg",
45
59
  "textDirection",
46
60
  "bidi",
47
61
  "rtlGutter",
@@ -125,49 +139,62 @@ pub(crate) fn ensure_ppr(xml: &mut DocxXml, paragraph: NodeId) -> NodeId {
125
139
  ppr
126
140
  }
127
141
 
142
+ /// `CT_PPr` child sequence (`CT_PPrBase` followed by `rPr`, `sectPr`,
143
+ /// `pPrChange`).
144
+ const PPR_SEQUENCE: &[&str] = &[
145
+ "pStyle",
146
+ "keepNext",
147
+ "keepLines",
148
+ "pageBreakBefore",
149
+ "framePr",
150
+ "widowControl",
151
+ "numPr",
152
+ "suppressLineNumbers",
153
+ "pBdr",
154
+ "shd",
155
+ "tabs",
156
+ "suppressAutoHyphens",
157
+ "kinsoku",
158
+ "wordWrap",
159
+ "overflowPunct",
160
+ "topLinePunct",
161
+ "autoSpaceDE",
162
+ "autoSpaceDN",
163
+ "bidi",
164
+ "adjustRightInd",
165
+ "snapToGrid",
166
+ "spacing",
167
+ "ind",
168
+ "contextualSpacing",
169
+ "mirrorIndents",
170
+ "suppressOverlap",
171
+ "jc",
172
+ "textDirection",
173
+ "textAlignment",
174
+ "textboxTightWrap",
175
+ "outlineLvl",
176
+ "divId",
177
+ "cnfStyle",
178
+ "rPr",
179
+ "sectPr",
180
+ "pPrChange",
181
+ ];
182
+
183
+ /// Return the `w:pPr` child `local`, creating it at its schema sequence
184
+ /// position.
128
185
  pub(crate) fn ensure_ppr_child(xml: &mut DocxXml, ppr: NodeId, local: &str) -> NodeId {
129
186
  if let Some(node) = xml.child_w(ppr, local) {
130
187
  return node;
131
188
  }
132
- let successors: &[&str] = match local {
133
- "pStyle" => &["*"],
134
- "spacing" => &[
135
- "ind",
136
- "contextualSpacing",
137
- "mirrorIndents",
138
- "suppressOverlap",
139
- "jc",
140
- "textDirection",
141
- "textAlignment",
142
- "textboxTightWrap",
143
- "outlineLvl",
144
- ],
145
- "ind" => &[
146
- "contextualSpacing",
147
- "mirrorIndents",
148
- "suppressOverlap",
149
- "jc",
150
- "textDirection",
151
- "textAlignment",
152
- "textboxTightWrap",
153
- "outlineLvl",
154
- ],
155
- "jc" => &[
156
- "textDirection",
157
- "textAlignment",
158
- "textboxTightWrap",
159
- "outlineLvl",
160
- ],
161
- _ => &[],
162
- };
163
189
  let node = xml.doc.create_element(&format!("w:{local}"));
164
- let successor = if successors.contains(&"*") {
165
- xml.doc.children(ppr).next()
166
- } else {
167
- xml.doc
168
- .children(ppr)
169
- .find(|&child| successors.iter().any(|name| xml.is_w(child, name)))
170
- };
190
+ let successors = PPR_SEQUENCE
191
+ .iter()
192
+ .position(|name| *name == local)
193
+ .map_or(&[][..], |index| &PPR_SEQUENCE[index + 1..]);
194
+ let successor = xml
195
+ .doc
196
+ .children(ppr)
197
+ .find(|&child| successors.iter().any(|name| xml.is_w(child, name)));
171
198
  if let Some(next) = successor {
172
199
  xml.doc.insert_before(next, node);
173
200
  } else {