docxdriver 0.3.1__tar.gz → 0.3.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 (72) hide show
  1. {docxdriver-0.3.1 → docxdriver-0.3.2}/Cargo.lock +5 -5
  2. {docxdriver-0.3.1 → docxdriver-0.3.2}/Cargo.toml +1 -1
  3. {docxdriver-0.3.1 → docxdriver-0.3.2}/PKG-INFO +1 -1
  4. docxdriver-0.3.2/crates/docxdriver-core/examples/list_scaling.rs +71 -0
  5. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/typed.rs +224 -1
  6. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/typed_api.rs +88 -0
  7. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/repl/prelude.py +1 -1
  8. {docxdriver-0.3.1 → docxdriver-0.3.2}/python/docxdriver/repl/prelude.py +1 -1
  9. {docxdriver-0.3.1 → docxdriver-0.3.2}/README.md +0 -0
  10. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/Cargo.toml +0 -0
  11. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/README.md +0 -0
  12. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/assets/blank.docx +0 -0
  13. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/examples/plan_scaling.rs +0 -0
  14. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/api.rs +0 -0
  15. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/bin/generate_typed_schema.rs +0 -0
  16. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/comments.rs +0 -0
  17. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/equations.rs +0 -0
  18. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/headers.rs +0 -0
  19. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/layout.rs +0 -0
  20. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/lists.rs +0 -0
  21. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/mod.rs +0 -0
  22. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/reading.rs +0 -0
  23. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/readonly.rs +0 -0
  24. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/revisions.rs +0 -0
  25. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/table_structure.rs +0 -0
  26. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/tables.rs +0 -0
  27. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/document.rs +0 -0
  28. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/README.md +0 -0
  29. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/assets.rs +0 -0
  30. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/build.rs +0 -0
  31. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/chrome.rs +0 -0
  32. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/compact.rs +0 -0
  33. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/ctx.rs +0 -0
  34. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/input.rs +0 -0
  35. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/mathml_omml.rs +0 -0
  36. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/mod.rs +0 -0
  37. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/omml_latex.rs +0 -0
  38. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/omml_mathml.rs +0 -0
  39. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/parse.rs +0 -0
  40. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/render.rs +0 -0
  41. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/styles.rs +0 -0
  42. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/surface.rs +0 -0
  43. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/lib.rs +0 -0
  44. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/media.rs +0 -0
  45. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/outcome.rs +0 -0
  46. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/package.rs +0 -0
  47. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/src/segments.rs +0 -0
  48. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/README.md +0 -0
  49. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/projection-parity/features.json +0 -0
  50. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/projection-parity/parity.docx +0 -0
  51. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/projection-parity/prior-projection.json +0 -0
  52. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/ts-reference.docx +0 -0
  53. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/formatting_fidelity.rs +0 -0
  54. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/html_e2e.rs +0 -0
  55. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/interop.rs +0 -0
  56. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/package_layout.rs +0 -0
  57. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/projection_parity.rs +0 -0
  58. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-core/tests/spike.rs +0 -0
  59. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/.gitignore +0 -0
  60. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/Cargo.toml +0 -0
  61. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/README.md +0 -0
  62. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/__init__.py +0 -0
  63. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/repl/__init__.py +0 -0
  64. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/repl/host.py +0 -0
  65. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/repl/quotes.py +0 -0
  66. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/src/lib.rs +0 -0
  67. {docxdriver-0.3.1 → docxdriver-0.3.2}/crates/docxdriver-python/tests/test_host.py +0 -0
  68. {docxdriver-0.3.1 → docxdriver-0.3.2}/pyproject.toml +0 -0
  69. {docxdriver-0.3.1 → docxdriver-0.3.2}/python/docxdriver/__init__.py +0 -0
  70. {docxdriver-0.3.1 → docxdriver-0.3.2}/python/docxdriver/repl/__init__.py +0 -0
  71. {docxdriver-0.3.1 → docxdriver-0.3.2}/python/docxdriver/repl/host.py +0 -0
  72. {docxdriver-0.3.1 → docxdriver-0.3.2}/python/docxdriver/repl/quotes.py +0 -0
@@ -416,7 +416,7 @@ dependencies = [
416
416
 
417
417
  [[package]]
418
418
  name = "docxdriver-cli"
419
- version = "0.3.1"
419
+ version = "0.3.2"
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.1"
430
+ version = "0.3.2"
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.1"
447
+ version = "0.3.2"
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.3.1"
465
+ version = "0.3.2"
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.3.1"
474
+ version = "0.3.2"
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.3.1"
6
+ version = "0.3.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.3.1
3
+ Version: 0.3.2
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,71 @@
1
+ //! List definition scaling probe: `cargo run --release -p docxdriver-core --example list_scaling -- PARAS OPS`.
2
+ use std::io::Write;
3
+ use std::time::Instant;
4
+
5
+ use docxdriver_core::{
6
+ execute_plan, ChangeMode, EditOp, ParagraphAddress, Plan, PlanRequest, PlanResult, SourceHash,
7
+ };
8
+
9
+ fn package(body: &str) -> Vec<u8> {
10
+ let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
11
+ let o = zip::write::SimpleFileOptions::default();
12
+ let parts = [
13
+ ("[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()),
14
+ ("_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()),
15
+ ("word/document.xml", body.to_string()),
16
+ ("word/_rels/document.xml.rels", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>".to_string()),
17
+ ];
18
+ for (n, c) in parts {
19
+ w.start_file(n, o).unwrap();
20
+ w.write_all(c.as_bytes()).unwrap();
21
+ }
22
+ w.finish().unwrap().into_inner()
23
+ }
24
+
25
+ fn main() {
26
+ let args: Vec<usize> = std::env::args()
27
+ .skip(1)
28
+ .map(|a| a.parse().unwrap())
29
+ .collect();
30
+ let (paras, ops) = (
31
+ args.first().copied().unwrap_or(1500),
32
+ args.get(1).copied().unwrap_or(300),
33
+ );
34
+ let body: String = (0..paras)
35
+ .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))
36
+ .collect();
37
+ 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>"));
38
+ let operations = (0..ops)
39
+ .map(|i| EditOp::SetList {
40
+ at: vec![ParagraphAddress::id(format!(
41
+ "{:08X}",
42
+ (i * paras / ops) + 1
43
+ ))],
44
+ num_id: None,
45
+ format: Some("decimal".into()),
46
+ level: 0,
47
+ start: None,
48
+ remove: false,
49
+ levels: None,
50
+ })
51
+ .collect();
52
+ let plan = Plan {
53
+ base: SourceHash::from_bytes(&bytes),
54
+ author: "bench".into(),
55
+ change_mode: ChangeMode::Track,
56
+ ops: operations,
57
+ };
58
+ let t = Instant::now();
59
+ let result = execute_plan(
60
+ &bytes,
61
+ &PlanRequest {
62
+ plan,
63
+ preview_key: None,
64
+ },
65
+ );
66
+ let ok = matches!(result, PlanResult::Previewed { .. });
67
+ println!(
68
+ "paras={paras} ops={ops} previewed={ok} elapsed={:?}",
69
+ t.elapsed()
70
+ );
71
+ }
@@ -26,6 +26,9 @@ use super::layout::{
26
26
  };
27
27
  use super::ExecCtx;
28
28
 
29
+ /// Internal engine policy; intentionally absent from the public plan schema.
30
+ const WORD_LEVEL_REDLINES: bool = true;
31
+
29
32
  struct SelectionProjection {
30
33
  current: String,
31
34
  to_all: Vec<usize>,
@@ -451,6 +454,7 @@ pub fn replace_text_typed(
451
454
  (&pieces, link_href),
452
455
  &input.author,
453
456
  input.tracked,
457
+ WORD_LEVEL_REDLINES,
454
458
  ctx,
455
459
  )?;
456
460
 
@@ -467,6 +471,190 @@ pub fn replace_text_typed(
467
471
  Ok((summary, result))
468
472
  }
469
473
 
474
+ /// Split words, whitespace runs, and individual punctuation characters. Offsets
475
+ /// are Unicode scalar offsets, matching the document segment engine.
476
+ fn diff_tokens(text: &str) -> (Vec<String>, Vec<usize>) {
477
+ let mut tokens = Vec::<String>::new();
478
+ let mut offsets = vec![0];
479
+ let mut previous = None;
480
+ for (index, ch) in text.chars().enumerate() {
481
+ let kind = if ch.is_alphanumeric() || ch == '_' {
482
+ 0
483
+ } else if ch.is_whitespace() {
484
+ 1
485
+ } else {
486
+ 2
487
+ };
488
+ if previous == Some(kind) && kind != 2 {
489
+ tokens.last_mut().unwrap().push(ch);
490
+ *offsets.last_mut().unwrap() = index + 1;
491
+ } else {
492
+ tokens.push(ch.to_string());
493
+ offsets.push(index + 1);
494
+ }
495
+ previous = Some(kind);
496
+ }
497
+ (tokens, offsets)
498
+ }
499
+
500
+ /// Unmatched old/new character spans around a deterministic token LCS.
501
+ fn word_diff(old: &str, new: &str) -> Result<Vec<(usize, usize, usize, usize)>, Outcome> {
502
+ if old == new {
503
+ return Ok(Vec::new());
504
+ }
505
+ let (a, ao) = diff_tokens(old);
506
+ let (b, bo) = diff_tokens(new);
507
+ let width = b.len() + 1;
508
+ let cells = (a.len() + 1)
509
+ .checked_mul(width)
510
+ .filter(|&n| n <= 4_000_000)
511
+ .ok_or_else(|| {
512
+ Outcome::blocked("word-level diff selection is too large; split it into smaller replacements")
513
+ })?;
514
+ let mut lcs = vec![0u32; cells];
515
+ for i in (0..a.len()).rev() {
516
+ for j in (0..b.len()).rev() {
517
+ lcs[i * width + j] = if a[i] == b[j] {
518
+ 1 + lcs[(i + 1) * width + j + 1]
519
+ } else {
520
+ lcs[(i + 1) * width + j].max(lcs[i * width + j + 1])
521
+ };
522
+ }
523
+ }
524
+ let (mut i, mut j, mut ai, mut bj) = (0, 0, 0, 0);
525
+ let mut spans = Vec::new();
526
+ while i < a.len() && j < b.len() {
527
+ if a[i] == b[j] {
528
+ if ai != i || bj != j {
529
+ spans.push((ao[ai], ao[i], bo[bj], bo[j]));
530
+ }
531
+ i += 1;
532
+ j += 1;
533
+ ai = i;
534
+ bj = j;
535
+ } else if lcs[(i + 1) * width + j] >= lcs[i * width + j + 1] {
536
+ i += 1;
537
+ } else {
538
+ j += 1;
539
+ }
540
+ }
541
+ if ai != a.len() || bj != b.len() {
542
+ spans.push((ao[ai], ao[a.len()], bo[bj], bo[b.len()]));
543
+ }
544
+ // Separator-only matches are weak anchors: keep a rewritten phrase as
545
+ // one deletion followed by one insertion rather than alternating revisions
546
+ // for every word. A retained word always separates independent changes.
547
+ let chars: Vec<char> = old.chars().collect();
548
+ let mut grouped: Vec<(usize, usize, usize, usize)> = Vec::new();
549
+ for span in spans {
550
+ if let Some(previous) = grouped.last_mut() {
551
+ if chars[previous.1..span.0].iter().all(|ch| !ch.is_alphanumeric() && *ch != '_') {
552
+ previous.1 = span.1;
553
+ previous.3 = span.3;
554
+ continue;
555
+ }
556
+ }
557
+ grouped.push(span);
558
+ }
559
+ Ok(grouped)
560
+ }
561
+
562
+ // Text diffing is an engine policy, not a public operation or plan option.
563
+ // Rich replacements retain the existing path so requested formatting is applied.
564
+ fn diff_eligible(pieces: &[InsPiece], link: Option<&str>) -> bool {
565
+ link.is_none() && pieces.iter().all(|piece| matches!(piece, InsPiece::Text(_, fmt) if *fmt == Fmt::default()))
566
+ }
567
+
568
+ #[allow(clippy::too_many_arguments)]
569
+ fn replace_range_diff(
570
+ package: &mut Package,
571
+ xml: &mut DocxXml,
572
+ paragraph: NodeId,
573
+ address: &str,
574
+ (start, end): (usize, usize),
575
+ pieces: &[InsPiece],
576
+ link: Option<String>,
577
+ author: &str,
578
+ ctx: &ExecCtx,
579
+ ) -> Result<(), Outcome> {
580
+ debug_assert!(diff_eligible(pieces, link.as_deref()));
581
+ let new: String = pieces.iter().filter_map(|piece| match piece {
582
+ InsPiece::Text(text, _) => Some(text.as_str()),
583
+ _ => None,
584
+ }).collect();
585
+ let segments = build_segments(xml, paragraph);
586
+ let covered: Vec<_> = segments
587
+ .iter()
588
+ .filter(|s| s.start < end && s.end > start)
589
+ .collect();
590
+ if covered.iter().any(|s| s.wrap != RevWrap::None) {
591
+ return Err(blocked_at(
592
+ address,
593
+ "word-level diff selection overlaps existing tracked changes; accept or reject them first",
594
+ ));
595
+ }
596
+ if let (Some(first), Some(last)) = (covered.first(), covered.last()) {
597
+ if let Some(label) = scan_blockers_between(
598
+ xml,
599
+ first.wrapper.unwrap_or(first.run),
600
+ last.wrapper.unwrap_or(last.run),
601
+ ) {
602
+ return Err(blocked_at(
603
+ address,
604
+ format!("word-level diff selection crosses a protected {label}"),
605
+ ));
606
+ }
607
+ }
608
+ let old: String = segments
609
+ .iter()
610
+ .flat_map(|s| s.text.chars())
611
+ .skip(start)
612
+ .take(end - start)
613
+ .collect();
614
+ let changes = match word_diff(&old, &new) {
615
+ Ok(changes) => changes,
616
+ Err(_) => return replace_range(package, xml, paragraph, address, (start, end), (pieces, link), author, true, false, ctx),
617
+ };
618
+ let chars: Vec<char> = new.chars().collect();
619
+ for (a, b, c, d) in changes.into_iter().rev() {
620
+ let replacement: String = chars[c..d].iter().collect();
621
+ let pieces = if replacement.is_empty() {
622
+ vec![]
623
+ } else {
624
+ vec![InsPiece::Text(replacement, Fmt::default())]
625
+ };
626
+ if a == b {
627
+ split_boundary(xml, paragraph, start + a);
628
+ let id = xml.next_revision_id();
629
+ insert_ins_pieces_at(
630
+ xml,
631
+ paragraph,
632
+ start + a,
633
+ &pieces,
634
+ id,
635
+ author,
636
+ &ctx.now,
637
+ false,
638
+ )
639
+ .map_err(Outcome::error)?;
640
+ } else {
641
+ replace_range(
642
+ package,
643
+ xml,
644
+ paragraph,
645
+ address,
646
+ (start + a, start + b),
647
+ (&pieces, None),
648
+ author,
649
+ true,
650
+ false,
651
+ ctx,
652
+ )?;
653
+ }
654
+ }
655
+ Ok(())
656
+ }
657
+
470
658
  /// Replace the `[start, end)` range of `paragraph` (all-text offsets, as
471
659
  /// returned by [`select_in_paragraph`]) with parsed `with` pieces: a tracked
472
660
  /// deletion plus insertion, or a direct rewrite. Offsets to the left of the
@@ -482,8 +670,24 @@ fn replace_range(
482
670
  (pieces, link_href): (&[InsPiece], Option<String>),
483
671
  author: &str,
484
672
  tracked: bool,
673
+ auto_diff: bool,
485
674
  ctx: &ExecCtx,
486
675
  ) -> Result<(), Outcome> {
676
+ if tracked && auto_diff && diff_eligible(pieces, link_href.as_deref())
677
+ && !build_segments(xml, paragraph).iter().any(|s| s.start < end && s.end > start && s.wrap != RevWrap::None)
678
+ {
679
+ return replace_range_diff(
680
+ package,
681
+ xml,
682
+ paragraph,
683
+ address,
684
+ (start, end),
685
+ pieces,
686
+ link_href,
687
+ author,
688
+ ctx,
689
+ );
690
+ }
487
691
  let address = address.to_string();
488
692
  if !tracked {
489
693
  let segments = build_segments(xml, paragraph);
@@ -828,6 +1032,7 @@ pub fn revise_text_typed(
828
1032
  (pieces, link.clone()),
829
1033
  &args.author,
830
1034
  args.tracked,
1035
+ WORD_LEVEL_REDLINES,
831
1036
  ctx,
832
1037
  )?;
833
1038
  }
@@ -883,7 +1088,25 @@ pub fn replace_paragraph_typed(
883
1088
  ));
884
1089
  }
885
1090
 
886
- if input.tracked {
1091
+ let count = build_segments(xml, paragraph)
1092
+ .iter()
1093
+ .map(|s| s.end)
1094
+ .max()
1095
+ .unwrap_or(0);
1096
+ if input.tracked && WORD_LEVEL_REDLINES && diff_eligible(&pieces, link_href.as_deref()) {
1097
+ replace_range(
1098
+ package,
1099
+ xml,
1100
+ paragraph,
1101
+ &address,
1102
+ (0, count),
1103
+ (&pieces, link_href),
1104
+ &input.author,
1105
+ true,
1106
+ true,
1107
+ ctx,
1108
+ )?;
1109
+ } else if input.tracked {
887
1110
  let del_id = xml.next_revision_id();
888
1111
  wrap_all_content_in_del(xml, paragraph, del_id, &input.author, &ctx.now)
889
1112
  .map_err(Outcome::error)?;
@@ -2409,3 +2409,91 @@ fn run_properties_are_written_in_sequence_order() {
2409
2409
  .collect();
2410
2410
  assert!(order.is_empty(), "out of CT_RPr sequence order at {order:?}: {rpr}");
2411
2411
  }
2412
+
2413
+ fn commit_auto_diff(bytes: &[u8], op: EditOp) -> Vec<u8> {
2414
+ let mut p = plan(bytes, vec![op]);
2415
+ p.change_mode = ChangeMode::Track;
2416
+ let key = match execute_plan(bytes, &PlanRequest { plan: p.clone(), preview_key: None }) {
2417
+ PlanResult::Previewed { preview_key, .. } => preview_key,
2418
+ other => panic!("auto_diff review failed: {other:?}"),
2419
+ };
2420
+ match execute_plan(bytes, &PlanRequest { plan: p, preview_key: Some(key) }) {
2421
+ PlanResult::Committed { bytes, .. } => bytes,
2422
+ other => panic!("auto_diff commit failed: {other:?}"),
2423
+ }
2424
+ }
2425
+
2426
+ #[test]
2427
+ fn auto_diff_tracks_only_changed_words_and_roundtrips_both_views() {
2428
+ for (old, new) in [
2429
+ ("Payment is due within thirty days.", "Payment is due within sixty days."),
2430
+ ("one two three four five", "one TWO three FOUR five"),
2431
+ ("hello world", "hello brave world"),
2432
+ ("hello brave world", "hello world"),
2433
+ ("hello", "hello!"),
2434
+ ("hello!", "hello"),
2435
+ ("café naïve résumé", "café wise résumé"),
2436
+ ("a a b a", "a b a a"),
2437
+ ("one two", "one two"),
2438
+ ("same words", "same words"),
2439
+ ("delete everything", ""),
2440
+ ("", "new text"),
2441
+ ] {
2442
+ for op in [
2443
+ EditOp::ReplaceParagraph { at: ParagraphAddress::id(SOURCE_ID), with: new.into() },
2444
+ revise(Some(SOURCE_ID), None, vec![edit(old, new)]),
2445
+ EditOp::ReplaceText { at: ParagraphAddress::id(SOURCE_ID), select: old.into(), with: new.into(), occurrence: None },
2446
+ ].into_iter().take(if old.is_empty() { 1 } else { 3 }) {
2447
+ let source = document(&[(SOURCE_ID, old)]);
2448
+ let changed = commit_auto_diff(&source, op);
2449
+ for (action, expected) in [(RevisionAction::Accept, new), (RevisionAction::Reject, old)] {
2450
+ let settled = commit_plan(&changed, vec![EditOp::RevisionSettle { target: RevisionTarget::All, action }]);
2451
+ let text = read_markup_view(&settled, Some("final"));
2452
+ let text: String = text.split('<').map(|s| s.split_once('>').map_or(s, |(_, t)| t)).collect();
2453
+ assert_eq!(text.trim(), expected, "{old:?} -> {new:?}");
2454
+ }
2455
+ let xml = document_xml(&changed);
2456
+ if old == new { assert!(!xml.contains("<w:ins") && !xml.contains("<w:del")); }
2457
+ if old.contains("thirty") {
2458
+ assert_eq!(xml.matches("<w:ins ").count(), 1);
2459
+ assert_eq!(xml.matches("<w:del ").count(), 1);
2460
+ assert!(xml.contains(">thirty</w:delText>"), "{xml}");
2461
+ assert!(xml.contains(">sixty</w:t>"), "{xml}");
2462
+ }
2463
+ }
2464
+ }
2465
+ }
2466
+
2467
+ #[test]
2468
+ fn auto_diff_preserves_unchanged_run_formatting_and_supports_rich_replacements() {
2469
+ let source = document(&[(SOURCE_ID, "The bold word stays old.")]);
2470
+ let source = commit_plan(&source, vec![serde_json::from_value(serde_json::json!({
2471
+ "op":"format_text", "at":SOURCE_ID,"select":"bold", "bold":true
2472
+ })).unwrap()]);
2473
+ let changed = commit_auto_diff(&source, EditOp::ReplaceParagraph {
2474
+ at: ParagraphAddress::id(SOURCE_ID), with: "The bold word stays new.".into(),
2475
+ });
2476
+ let xml = document_xml(&changed);
2477
+ assert!(xml.contains("<w:b"));
2478
+ assert!(xml.contains(">bold</w:t>"));
2479
+ let text = revise_final(&source, ChangeMode::Track, vec![revise(Some(SOURCE_ID), None, vec![edit("old.", "<b>new.</b>")])]).unwrap();
2480
+ assert!(text.contains("stays new."), "{text}");
2481
+ }
2482
+
2483
+ #[test]
2484
+ fn auto_diff_groups_rewritten_phrases_but_keeps_unchanged_words() {
2485
+ for (old, new, groups) in [
2486
+ ("old red car", "new blue truck", 1),
2487
+ ("old, red car", "new, blue truck", 1),
2488
+ ("old red car stays here", "new blue truck stays there", 2),
2489
+ ] {
2490
+ let source = document(&[(SOURCE_ID, old)]);
2491
+ let output = commit_auto_diff(&source, EditOp::ReplaceParagraph {
2492
+ at: ParagraphAddress::id(SOURCE_ID), with: new.into(),
2493
+ });
2494
+ let xml = document_xml(&output);
2495
+ // Each contiguous changed phrase has at most one revision of each type.
2496
+ assert_eq!(xml.matches("<w:del ").count(), groups, "{xml}");
2497
+ assert_eq!(xml.matches("<w:ins ").count(), groups, "{xml}");
2498
+ }
2499
+ }
@@ -1,3 +1,3 @@
1
1
  # Generated by scripts/generate-docxdriver-repl-prelude.py; edit the shared Pi templates.
2
2
  PRELUDE = "from dataclasses import dataclass\n\n@dataclass\nclass QuoteResult:\n text: str\n provenance: dict\n\n@dataclass\nclass TermResult:\n text: str\n provenance: dict\n\nclass Term:\n\n def __init__(self, text: str, style: str='double'):\n self.text = text\n self.style = style\n\n def to_dict(self):\n return {'$kind': 'term', 'text': self.text, 'style': self.style}\n\n@dataclass\nclass InlineResult:\n text: str\n audit: dict\n\nclass Inline:\n\n def __init__(self, parts: list):\n self.parts = parts\n\n def to_dict(self):\n return {'$kind': 'inline', 'parts': [part.to_dict() if hasattr(part, 'to_dict') else part for part in self.parts]}\n\nclass Quote:\n\n def __init__(self, source: str, at: str, select: str, occurrence: int=1, style: str='double'):\n self.source = source\n self.at = at\n self.select = select\n self.occurrence = occurrence\n self.style = style\n self.changes = []\n\n def omit(self, select: str, occurrence: int=1, marker: str='…'):\n self.changes.append({'kind': 'omit', 'select': select, 'occurrence': occurrence, 'marker': marker})\n return self\n\n def bracket(self, select: str, replacement: str, occurrence: int=1):\n self.changes.append({'kind': 'bracket', 'select': select, 'replacement': replacement, 'occurrence': occurrence})\n return self\n\n def to_dict(self):\n return {'$kind': 'quote', 'source': self.source, 'at': self.at, 'select': self.select, 'occurrence': self.occurrence, 'style': self.style, 'changes': self.changes}\n\nasync def quote_find(source: str, query: str, ignore_case: bool=False) -> list:\n return await _quote_find(source, query, ignore_case)\n\nasync def quote_validate(quote: Quote) -> QuoteResult:\n result = await _quote_validate(quote.to_dict())\n return QuoteResult(text=result['text'], provenance=result)\n\nasync def quote_lint(text: str, severity: str='error') -> list:\n return await _quote_lint(text, severity)\n\nasync def term_render(term: Term) -> TermResult:\n result = await _term_render(term.to_dict())\n return TermResult(text=result['text'], provenance=result)\n\nasync def inline_validate(inline: Inline, policy: str='error') -> InlineResult:\n result = await _inline_validate(inline.to_dict(), policy)\n return InlineResult(text=result['text'], audit=result['audit'])\nfrom dataclasses import dataclass\nimport re\n\n@dataclass\nclass ClearFooter:\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'clear_footer'}\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass ClearHeader:\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'clear_header'}\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass CommentAdd:\n at: str\n text: str\n occurrence: int | None = None\n select: str | None = None\n\n def to_dict(self):\n value = {'op': 'comment_add'}\n if self.at is not None:\n value['at'] = self.at\n if self.text is not None:\n value['text'] = self.text\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n if self.select is not None:\n value['select'] = self.select\n return value\n\n@dataclass\nclass CommentDelete:\n comment_id: str\n\n def to_dict(self):\n value = {'op': 'comment_delete'}\n if self.comment_id is not None:\n value['comment_id'] = self.comment_id\n return value\n\n@dataclass\nclass CommentReply:\n comment_id: str\n text: str\n\n def to_dict(self):\n value = {'op': 'comment_reply'}\n if self.comment_id is not None:\n value['comment_id'] = self.comment_id\n if self.text is not None:\n value['text'] = self.text\n return value\n\n@dataclass\nclass CommentSetStatus:\n comment_id: str\n status: str\n\n def to_dict(self):\n value = {'op': 'comment_set_status'}\n if self.comment_id is not None:\n value['comment_id'] = self.comment_id\n if self.status is not None:\n value['status'] = self.status\n return value\n\n@dataclass\nclass DeleteEquation:\n at: str\n equation: int | None = None\n\n def to_dict(self):\n value = {'op': 'delete_equation'}\n if self.at is not None:\n value['at'] = self.at\n if self.equation is not None:\n value['equation'] = self.equation\n return value\n\n@dataclass\nclass DeleteParagraphs:\n at: list\n\n def to_dict(self):\n value = {'op': 'delete_paragraphs'}\n if self.at is not None:\n value['at'] = self.at\n return value\n\n@dataclass\nclass EditTable:\n action: dict\n at: str\n\n def to_dict(self):\n value = {'op': 'edit_table'}\n if self.action is not None:\n value['action'] = self.action\n if self.at is not None:\n value['at'] = self.at\n return value\n\n@dataclass\nclass FormatParagraph:\n at: str\n alignment: str | None = None\n clear: list | None = None\n first_line_indent: float | None = None\n indent_left: float | None = None\n indent_right: float | None = None\n line_spacing: dict | None = None\n space_after: float | None = None\n space_before: float | None = None\n style: str | None = None\n\n def to_dict(self):\n value = {'op': 'format_paragraph'}\n if self.at is not None:\n value['at'] = self.at\n if self.alignment is not None:\n value['alignment'] = self.alignment\n if self.clear is not None:\n value['clear'] = self.clear\n if self.first_line_indent is not None:\n value['first_line_indent'] = self.first_line_indent\n if self.indent_left is not None:\n value['indent_left'] = self.indent_left\n if self.indent_right is not None:\n value['indent_right'] = self.indent_right\n if self.line_spacing is not None:\n value['line_spacing'] = self.line_spacing\n if self.space_after is not None:\n value['space_after'] = self.space_after\n if self.space_before is not None:\n value['space_before'] = self.space_before\n if self.style is not None:\n value['style'] = self.style\n return value\n\n@dataclass\nclass FormatText:\n at: str\n select: str\n bold: bool | None = None\n clear: list | None = None\n color: str | None = None\n font_size: float | None = None\n highlight: str | None = None\n italic: bool | None = None\n occurrence: int | None = None\n strike: bool | None = None\n subscript: bool | None = None\n superscript: bool | None = None\n underline: bool | None = None\n\n def to_dict(self):\n value = {'op': 'format_text'}\n if self.at is not None:\n value['at'] = self.at\n if self.select is not None:\n value['select'] = self.select\n if self.bold is not None:\n value['bold'] = self.bold\n if self.clear is not None:\n value['clear'] = self.clear\n if self.color is not None:\n value['color'] = self.color\n if self.font_size is not None:\n value['font_size'] = self.font_size\n if self.highlight is not None:\n value['highlight'] = self.highlight\n if self.italic is not None:\n value['italic'] = self.italic\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n if self.strike is not None:\n value['strike'] = self.strike\n if self.subscript is not None:\n value['subscript'] = self.subscript\n if self.superscript is not None:\n value['superscript'] = self.superscript\n if self.underline is not None:\n value['underline'] = self.underline\n return value\n\n@dataclass\nclass InsertParagraph:\n at: str\n position: str\n as_: str | None = None\n style: str | None = None\n with_: str | Inline | Quote | Term | None = None\n\n def to_dict(self):\n value = {'op': 'insert_paragraph'}\n if self.at is not None:\n value['at'] = self.at\n if self.position is not None:\n value['position'] = self.position\n if self.as_ is not None:\n value['as'] = self.as_\n if self.style is not None:\n value['style'] = self.style\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n return value\n\n@dataclass\nclass InsertTable:\n at: str\n columns: int\n position: str\n rows: int\n as_: str | None = None\n\n def to_dict(self):\n value = {'op': 'insert_table'}\n if self.at is not None:\n value['at'] = self.at\n if self.columns is not None:\n value['columns'] = self.columns\n if self.position is not None:\n value['position'] = self.position\n if self.rows is not None:\n value['rows'] = self.rows\n if self.as_ is not None:\n value['as'] = self.as_\n return value\n\n@dataclass\nclass ReplaceEquation:\n at: str\n mathml: str\n display: bool | None = None\n equation: int | None = None\n\n def to_dict(self):\n value = {'op': 'replace_equation'}\n if self.at is not None:\n value['at'] = self.at\n if self.mathml is not None:\n value['mathml'] = self.mathml\n if self.display is not None:\n value['display'] = self.display\n if self.equation is not None:\n value['equation'] = self.equation\n return value\n\n@dataclass\nclass ReplaceParagraph:\n at: str\n with_: str | Inline | Quote | Term\n\n def to_dict(self):\n value = {'op': 'replace_paragraph'}\n if self.at is not None:\n value['at'] = self.at\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n return value\n\n@dataclass\nclass ReplaceText:\n at: str\n select: str\n with_: str | Inline | Quote | Term\n occurrence: int | None = None\n\n def to_dict(self):\n value = {'op': 'replace_text'}\n if self.at is not None:\n value['at'] = self.at\n if self.select is not None:\n value['select'] = self.select\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n return value\n\n@dataclass\nclass ReviseText:\n edits: list\n at: str | None = None\n occurrence: int | None = None\n within: str | None = None\n\n def to_dict(self):\n value = {'op': 'revise_text'}\n if self.edits is not None:\n value['edits'] = self.edits\n if self.at is not None:\n value['at'] = self.at\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n if self.within is not None:\n value['within'] = self.within\n return value\n\n@dataclass\nclass RevisionSettle:\n action: str\n target: str\n\n def to_dict(self):\n value = {'op': 'revision_settle'}\n if self.action is not None:\n value['action'] = self.action\n if self.target is not None:\n value['target'] = self.target\n return value\n\n@dataclass\nclass SetEvenAndOddHeaders:\n even_and_odd: bool\n\n def to_dict(self):\n value = {'op': 'set_even_and_odd_headers'}\n if self.even_and_odd is not None:\n value['even_and_odd'] = self.even_and_odd\n return value\n\n@dataclass\nclass SetFirstPageMode:\n enabled: bool\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_first_page_mode'}\n if self.enabled is not None:\n value['enabled'] = self.enabled\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass SetFooter:\n with_: str | Inline | Quote | Term\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_footer'}\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass SetHeader:\n with_: str | Inline | Quote | Term\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_header'}\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass SetList:\n at: list\n format: str | None = None\n level: int | None = None\n levels: list | None = None\n num_id: int | None = None\n remove: bool | None = None\n start: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_list'}\n if self.at is not None:\n value['at'] = self.at\n if self.format is not None:\n value['format'] = self.format\n if self.level is not None:\n value['level'] = self.level\n if self.levels is not None:\n value['levels'] = self.levels\n if self.num_id is not None:\n value['num_id'] = self.num_id\n if self.remove is not None:\n value['remove'] = self.remove\n if self.start is not None:\n value['start'] = self.start\n return value\n\n@dataclass\nclass SetPageMargins:\n bottom: float | None = None\n footer: float | None = None\n gutter: float | None = None\n header: float | None = None\n left: float | None = None\n right: float | None = None\n section: int | None = None\n top: float | None = None\n\n def to_dict(self):\n value = {'op': 'set_page_margins'}\n if self.bottom is not None:\n value['bottom'] = self.bottom\n if self.footer is not None:\n value['footer'] = self.footer\n if self.gutter is not None:\n value['gutter'] = self.gutter\n if self.header is not None:\n value['header'] = self.header\n if self.left is not None:\n value['left'] = self.left\n if self.right is not None:\n value['right'] = self.right\n if self.section is not None:\n value['section'] = self.section\n if self.top is not None:\n value['top'] = self.top\n return value\n\n@dataclass\nclass SetSectionLayout:\n break_type: str | None = None\n column_separator: bool | None = None\n column_spacing: float | None = None\n column_widths: list | None = None\n columns: int | None = None\n height: float | None = None\n orientation: str | None = None\n page_number_continue: bool | None = None\n page_number_format: str | None = None\n page_number_start: int | None = None\n page_size: str | None = None\n section: int | None = None\n width: float | None = None\n\n def to_dict(self):\n value = {'op': 'set_section_layout'}\n if self.break_type is not None:\n value['break_type'] = self.break_type\n if self.column_separator is not None:\n value['column_separator'] = self.column_separator\n if self.column_spacing is not None:\n value['column_spacing'] = self.column_spacing\n if self.column_widths is not None:\n value['column_widths'] = self.column_widths\n if self.columns is not None:\n value['columns'] = self.columns\n if self.height is not None:\n value['height'] = self.height\n if self.orientation is not None:\n value['orientation'] = self.orientation\n if self.page_number_continue is not None:\n value['page_number_continue'] = self.page_number_continue\n if self.page_number_format is not None:\n value['page_number_format'] = self.page_number_format\n if self.page_number_start is not None:\n value['page_number_start'] = self.page_number_start\n if self.page_size is not None:\n value['page_size'] = self.page_size\n if self.section is not None:\n value['section'] = self.section\n if self.width is not None:\n value['width'] = self.width\n return value\n\n@dataclass\nclass Revise:\n within: str | None = None\n at: str | None = None\n occurrence: int | None = None\n edits: list = None\n\n def replace(self, select: str, with_, occurrence: int | None=None, all: bool=False):\n edit = {'select': select, 'with': with_.to_dict() if hasattr(with_, 'to_dict') else with_}\n if occurrence is not None:\n edit['occurrence'] = occurrence\n if all:\n edit['all'] = True\n self.edits = (self.edits or []) + [edit]\n return self\n\n def to_dict(self):\n return ReviseText(edits=self.edits or [], at=self.at, occurrence=self.occurrence, within=self.within).to_dict()\n\ndef AcceptRevision(target: str) -> RevisionSettle:\n return RevisionSettle(action='accept', target=str(target))\n\ndef RejectRevision(target: str) -> RevisionSettle:\n return RevisionSettle(action='reject', target=str(target))\n\n@dataclass\nclass ReviewResult:\n commit_key: str\n edits: list\n\n@dataclass\nclass ReadResult:\n markup: str\n equations: list = None\n selection_space: dict = None\n selections: dict = None\n comments: list = None\n assets: list = None\n comments_error: str = None\n styles: dict = None\n css: dict = None\n\n def __repr__(self):\n return 'ReadResult(markup=' + repr(self.markup) + ', styles=' + str(len(self.styles or {})) + ' definitions, css=' + str(len(self.css or {})) + ' rules; inspect .styles and .css, equations=' + repr(self.equations) + ', selection_space=' + repr(self.selection_space) + ', selections=' + repr(self.selections) + ', comments=' + repr(self.comments) + ', assets=' + repr(self.assets) + ', comments_error=' + repr(self.comments_error) + ')'\n\n@dataclass\nclass Plan:\n author: str = 'docxdriver'\n change_mode: str = 'track'\n operations: list = None\n\n def to_dict(self):\n ops = self.operations if self.operations is not None else []\n return {'author': self.author, 'change_mode': self.change_mode, 'ops': [op.to_dict() if hasattr(op, 'to_dict') else op for op in ops]}\n\nasync def docx_create(path: str, html: str):\n return await _docx_create(path, html)\n\nasync def docx_read(path: str, view: str='markup', kind: str='document'):\n _listing_kinds = ('comments', 'styles', 'revisions', 'assets')\n if kind == 'document' and view in _listing_kinds:\n kind = view\n if kind in _listing_kinds:\n return await _docx_read(path, view, kind)\n if kind != 'document':\n raise ValueError('kind must be one of document, comments, styles, revisions, assets')\n result = await _docx_read(path, view)\n return ReadResult(markup=result['markup'], styles=result.get('styles', {}), css=result.get('css', {}), equations=result.get('equations', []), selection_space=result.get('selection_space'), selections=result.get('selections'), comments=result.get('comments', []), assets=result.get('assets', []), comments_error=result.get('comments_error'))\n\nasync def docx_find(path: str, query: str, ignore_case: bool=False) -> list:\n return await _docx_find(path, query, ignore_case)\n\nasync def docx_help(topic: str | None=None) -> str:\n return await _docx_help(topic)\n\nasync def docx_review(path: str, plan: Plan) -> ReviewResult | None:\n result = await _docx_review(path, {'plan': plan.to_dict()})\n if result is None:\n return None\n return ReviewResult(commit_key=result['commit_key'], edits=result['edits'])\n\nasync def docx_commit(commit_key: str):\n await _docx_commit(commit_key)\n return None"
3
- REFERENCE = '\n# docxdriver Python REPL API — Version 1 (Python-authored plan)\n\n# BEGIN GENERATED OPERATION DOCS\n## Operations\n\nSelected from the core schema (`$defs.editOp.oneOf`) through the\ncapability manifest; field shapes, types, and enums are exactly the\nchecked-in schema’s. Every op dataclass has `to_dict()`, omitting\noptional fields whose value is `None`; constructors use `with_`/`as_`\nfor Python keyword collisions and `to_dict()` maps back to the schema\nfield names. Operations are addressed by the paragraph `id` attribute\nfrom `docx_read` (uppercase eight-digit hex) or a `$name` alias.\n\n### ClearFooter\n\n`ClearFooter(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit footer reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### ClearHeader\n\n`ClearHeader(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit header reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### CommentAdd\n\n`CommentAdd(at: str, text: str, occurrence: int | None = None, select: str | None = None)`\n\nAnchor a comment with `text` to the paragraph at `at`; omit `select` to comment the whole paragraph, or pass `select`/`occurrence` (1-based) to pin one exact span.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `text` | `str` | yes |\n| `occurrence` | `int | None` | no |\n| `select` | `str | None` | no |\n\n### CommentDelete\n\n`CommentDelete(comment_id: str)`\n\nDelete a comment by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n\n### CommentReply\n\n`CommentReply(comment_id: str, text: str)`\n\nReply to an existing comment; `comment_id` is the decimal id from a prior `comment_add` or a comments-kind read.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `text` | `str` | yes |\n\n### CommentSetStatus\n\n`CommentSetStatus(comment_id: str, status: str)`\n\nSet a comment\'s status to `open` or `resolved` by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `status` | `str` | yes |\n\n### DeleteEquation\n\n`DeleteEquation(at: str, equation: int | None = None)`\n\nDelete a current equation while preserving the paragraph. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Tracked mode keeps the equation recoverable until the deletion is accepted.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `equation` | `int | None` | no |\n\n### DeleteParagraphs\n\n`DeleteParagraphs(at: list)`\n\nDelete the paragraphs listed in `at` (tracked deletion by default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n\n### EditTable\n\n`EditTable(action: dict, at: str)`\n\nDirect structural table edits using a source-bound table handle. action.kind is insert_rows/delete_rows/insert_columns/delete_columns/merge_cells/split_cell/delete; coordinates and counts are one-based. Insert/delete grid operations and merge require an unmerged rectangular grid. Split keeps content in the first resulting cell; merge concatenates content in row-major order. Rejects tracked mode and tables containing unresolved revisions, bookmarks, comments, fields or content controls. Delete the table instead of its final row or column.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `dict` | yes |\n| `at` | `str` | yes |\n\n### FormatParagraph\n\n`FormatParagraph(at: str, alignment: str | None = None, clear: list | None = None, first_line_indent: float | None = None, indent_left: float | None = None, indent_right: float | None = None, line_spacing: dict | None = None, space_after: float | None = None, space_before: float | None = None, style: str | None = None)`\n\nApply paragraph formatting to the paragraph at `at`: `style`, `alignment` (left/center/right/justify), signed `indent_left`/`indent_right`, signed `first_line_indent` (negative for hanging, zero for an explicit zero offset), `space_before`/`space_after`, `line_spacing` (dict with `mode` multiple/exact/at_least and `value`), or `clear`. Omitted properties are untouched; clear removes direct settings and inherits styles/defaults, winning over set. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `alignment` | `str | None` | no |\n| `clear` | `list | None` | no |\n| `first_line_indent` | `float | None` | no |\n| `indent_left` | `float | None` | no |\n| `indent_right` | `float | None` | no |\n| `line_spacing` | `dict | None` | no |\n| `space_after` | `float | None` | no |\n| `space_before` | `float | None` | no |\n| `style` | `str | None` | no |\n\n### FormatText\n\n`FormatText(at: str, select: str, bold: bool | None = None, clear: list | None = None, color: str | None = None, font_size: float | None = None, highlight: str | None = None, italic: bool | None = None, occurrence: int | None = None, strike: bool | None = None, subscript: bool | None = None, superscript: bool | None = None, underline: bool | None = None)`\n\nApply character formatting to an exact occurrence of `select` in the paragraph at `at`: boolean attributes (`bold`, `italic`, `underline`, `strike`, `superscript`, `subscript`), `color` (#RRGGBB), `highlight` (Word palette including none), `font_size` (points), `occurrence`, or `clear`. Omitted properties preserve source OOXML; false explicitly overrides inherited formatting (baseline for superscript/subscript). Clear removes the direct property and inherits styles/defaults, winning over set; superscript/subscript share one property. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `bold` | `bool | None` | no |\n| `clear` | `list | None` | no |\n| `color` | `str | None` | no |\n| `font_size` | `float | None` | no |\n| `highlight` | `str | None` | no |\n| `italic` | `bool | None` | no |\n| `occurrence` | `int | None` | no |\n| `strike` | `bool | None` | no |\n| `subscript` | `bool | None` | no |\n| `superscript` | `bool | None` | no |\n| `underline` | `bool | None` | no |\n\n### InsertParagraph\n\n`InsertParagraph(at: str, position: str, as_: str | None = None, style: str | None = None, with_: str | Inline | Quote | Term | None = None)`\n\nInsert a new paragraph before or after an anchor: paragraph id/alias (`at`) or body-level table number (`table:N` from markup); `with` is its text, `style` its style, and `as` names the new paragraph id (a `$name` alias).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `position` | `str` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n| `style` | `str | None` | no |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term | None` | no |\n\n### InsertTable\n\n`InsertTable(at: str, columns: int, position: str, rows: int, as_: str | None = None)`\n\nInsert a rectangular table before/after paragraph at, with rows and columns in 1..100. Direct mode only. Optional as creates a plan-local table alias for later formatting and structural edits.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `columns` | `int` | yes |\n| `position` | `str` | yes |\n| `rows` | `int` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n\n### ReplaceEquation\n\n`ReplaceEquation(at: str, mathml: str, display: bool | None = None, equation: int | None = None)`\n\nReplace a current equation using presentation MathML. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Display mode is preserved by default. Settle existing revisions before editing their equations.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `mathml` | `str` | yes |\n| `display` | `bool | None` | no |\n| `equation` | `int | None` | no |\n\n### ReplaceParagraph\n\n`ReplaceParagraph(at: str, with_: str | Inline | Quote | Term)`\n\nReplace the entire paragraph at `at` with `with`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n\n### ReplaceText\n\n`ReplaceText(at: str, select: str, with_: str | Inline | Quote | Term, occurrence: int | None = None)`\n\nReplace an exact occurrence of `select` in the paragraph at `at` with `with`; `occurrence` picks the nth match (1-based).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `occurrence` | `int | None` | no |\n\n### ReviseText\n\n`ReviseText(edits: list, at: str | None = None, occurrence: int | None = None, within: str | None = None)`\n\nSeveral exact replacements inside one bounded scope; prefer the chainable `Revise(within, at=None, occurrence=None).replace(select, with_, occurrence=None, all=False)` builder. Scope: `within` (a phrase unique in paragraph `at`, or in the whole document when `at` is omitted; `occurrence` picks a repeated one), else the paragraph `at`, else the whole document. Each edit must match exactly once in the scope unless it sets `occurrence` or `all`. All edits resolve against the scope as it stands when the operation starts, must not overlap, and become separate minimal tracked changes.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `edits` | `list` | yes |\n| `at` | `str | None` | no |\n| `occurrence` | `int | None` | no |\n| `within` | `str | None` | no |\n\n### RevisionSettle\n\n`RevisionSettle(action: str, target: str)`\n\nAccept or reject tracked changes: `target` is a decimal revision id from `await docx_read(path, kind="revisions")` or `all`; `action` is `accept` or `reject`. Prefer the `AcceptRevision(target)` / `RejectRevision(target)` shorthands. Settling `all` also settles tracked changes made by earlier operations in the same plan, and later selectors address the settled text.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `str` | yes |\n| `target` | `str` | yes |\n\n### SetEvenAndOddHeaders\n\n`SetEvenAndOddHeaders(even_and_odd: bool)`\n\nEnable or disable distinct even-page headers/footers (`even_and_odd`); when true, `kind="even"` chrome applies to even pages.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `even_and_odd` | `bool` | yes |\n\n### SetFirstPageMode\n\n`SetFirstPageMode(enabled: bool, section: int | None = None)`\n\nEnable/disable first-page stories in a section (default 1) without removing stored stories. Tracked mode records a section-property snapshot; settle pending section changes before editing again.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `enabled` | `bool` | yes |\n| `section` | `int | None` | no |\n\n### SetFooter\n\n`SetFooter(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section footer chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetHeader\n\n`SetHeader(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section header chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetList\n\n`SetList(at: list, format: str | None = None, level: int | None = None, levels: list | None = None, num_id: int | None = None, remove: bool | None = None, start: int | None = None)`\n\nAssign explicitly selected paragraphs to a new list (format defaults to decimal), continue an existing num_id, or remove membership with remove=True. level is 0..8; start is positive and creates an isolated restarted instance. Optional levels defines 1..9 per-level format/label/start/restart_after, signed indent_left/indent_right/first_line_indent and marker_bold/marker_italic/marker_font/marker_color for a new list. Label placeholders reference this or ancestor levels (%1..%9). Existing definitions are never restyled. Membership changes are tracked; settle pending paragraph changes first. Definitions remain after rejection for safe reference restoration.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n| `format` | `str | None` | no |\n| `level` | `int | None` | no |\n| `levels` | `list | None` | no |\n| `num_id` | `int | None` | no |\n| `remove` | `bool | None` | no |\n| `start` | `int | None` | no |\n\n### SetPageMargins\n\n`SetPageMargins(bottom: float | None = None, footer: float | None = None, gutter: float | None = None, header: float | None = None, left: float | None = None, right: float | None = None, section: int | None = None, top: float | None = None)`\n\nSet selected nonnegative page margins in points for a section (default 1): top/right/bottom/left/header/footer/gutter. Omitted values are preserved. Like existing header/footer setters, margin changes are direct even in tracked plans.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `bottom` | `float | None` | no |\n| `footer` | `float | None` | no |\n| `gutter` | `float | None` | no |\n| `header` | `float | None` | no |\n| `left` | `float | None` | no |\n| `right` | `float | None` | no |\n| `section` | `int | None` | no |\n| `top` | `float | None` | no |\n\n### SetSectionLayout\n\n`SetSectionLayout(break_type: str | None = None, column_separator: bool | None = None, column_spacing: float | None = None, column_widths: list | None = None, columns: int | None = None, height: float | None = None, orientation: str | None = None, page_number_continue: bool | None = None, page_number_format: str | None = None, page_number_start: int | None = None, page_size: str | None = None, section: int | None = None, width: float | None = None)`\n\nSelectively set section layout (section defaults to 1): page_size letter/legal/a4/a3/a5 or paired width/height in points; orientation portrait/landscape normalizes dimensions. Columns 1..45 with column_spacing, column_widths and column_separator; break_type nextPage/continuous/oddPage/evenPage/nextColumn; page_number_format, page_number_start or page_number_continue=true. Omission preserves properties; tracking snapshots section properties while preserving story relationships. Pending property changes must be settled first.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `break_type` | `str | None` | no |\n| `column_separator` | `bool | None` | no |\n| `column_spacing` | `float | None` | no |\n| `column_widths` | `list | None` | no |\n| `columns` | `int | None` | no |\n| `height` | `float | None` | no |\n| `orientation` | `str | None` | no |\n| `page_number_continue` | `bool | None` | no |\n| `page_number_format` | `str | None` | no |\n| `page_number_start` | `int | None` | no |\n| `page_size` | `str | None` | no |\n| `section` | `int | None` | no |\n| `width` | `float | None` | no |\n\n# END GENERATED OPERATION DOCS\n\n## Scoped revisions\n\n`Revise(within=None, at=None, occurrence=None)` builds one `ReviseText`\noperation; chain `.replace(select, with_, occurrence=None, all=False)` for each\nchange. The scope bounds every `select`, so short selectors stay unambiguous\nin long documents and each change becomes a minimal tracked change:\n\n # unique phrase, no paragraph id needed:\n Revise("the tenant shall pay rent within thirty days").replace("thirty", "sixty").replace("rent", "the rent")\n Revise(at="2673269E").replace("Tenant", "Lessee", all=True) # whole paragraph\n Revise("Section 4(b)", at="2673269E", occurrence=2).replace("4(b)", "4(c)")\n Revise().replace("Acme Corp.", "Acme, Inc.", all=True) # whole document\n\n`within` must occur exactly once in its paragraph (`at`) or document unless\n`occurrence` picks one. Each `select` must occur exactly once in the scope\nunless it passes `occurrence` or `all=True`. All selectors resolve against the\nscope as it stands when the operation starts, so later replacements never see\nearlier ones; overlapping selections are rejected.\n\n`AcceptRevision(target)` and `RejectRevision(target)` build `RevisionSettle`\noperations; `target` is a revision id from `await docx_read(path, kind="revisions")`\nor `"all"`. Put settle operations first: `"all"` also settles tracked changes\nmade earlier in the same plan.\n\nEvery operation dataclass has `to_dict()`, omitting optional fields whose\nvalue is `None`; `Plan.to_dict()` returns `{"author": ..., "change_mode": ..., "ops": [...]}`.\n\n## Host functions\n\n- `await docx_create(path: str, html: str)`\n- `await docx_read(path: str, view: str = "markup", kind: str = "document") -> ReadResult | dict` — complete HTML\n markup and a current equations-address listing (at, equation, display, editable) by default (`kind="document"`); `kind="comments"`, `"styles"`, `"revisions"`, or `"assets"` returns the\n core listing object (and a second positional of `comments`/`styles`/`revisions` is treated as `kind`).\n- `await docx_find(path: str, query: str, ignore_case: bool = False) -> list` — core find match\n records verbatim (`{index, id?, text, style?, number?}` per matching paragraph; `id` is\n the canonical uppercase paragraph address)\n- `await docx_help(topic: str | None = None) -> str`\n- `await docx_review(path: str, plan: Plan) -> ReviewResult | None` — validates the complete plan and returns `commit_key` plus bounded per-operation `edits` with affected markup context; returns `None` when validation is blocked\n- `await docx_commit(commit_key: str)` — commits the immutable reviewed plan; the key owns the\n target file and plan\nReview does not write the target. Review and commit must happen in separate later\nPython calls; changing the plan requires a new review.\n\n## Discovering paragraphs\n\n`docx_find` plus `docx_read` are the supported discovery workflow:\nfind returns core match records to locate paragraphs, and reads return\nstructural markup. Operations stay addressed\nby the markup-view paragraph `id`:\n\n matches = await docx_find("contract.docx", "Payment is due within")\n for m in matches:\n print(m["id"], m["text"])\n markup = (await docx_read("contract.docx")).markup\n\nReading `view="final"` or `view="original"` is allowed for analysis, but\nreview plans remain source-bound to the exact bytes and paragraph ids of the\ncurrent `markup` view.\n\n## Computing operations with regex\n\n`re` is pre-imported and `ReadResult.markup` is an ordinary string, so you can\ninspect it and build operations in Python loops. Return the full ReadResult or\nprint its full markup and semantic metadata when reading; prefer complete document\ncontext. Named DOCX style IDs appear in data-docx-style and map one-to-one to\nReadResult.styles (resolved CSS keyed by style ID). Presentation rules appear\nin data-docx-css and map to ReadResult.css. Both catalogs are omitted from the\ndefault display; inspect result.styles or result.css to read their definitions;\nwhen authoring a fragment, use equivalent inline formatting rather than copying\nan unresolved class. Image aliases map to canonical assets in ReadResult.assets.\nReadResult.selection_space describes comment selectors; ReadResult.selections\nprovides exact text for paragraphs where generated content or revisions make\nHTML text ambiguous:\n\n\n original = (await docx_read("contract.docx")).markup\n ops = []\n for m in re.finditer(r\'<p id="([0-9A-F]{8})"[^>]*>([^<]*?)</p>\', original):\n if "Payment is due within" in m.group(2):\n ops.append(ReplaceText(at=m.group(1), select=m.group(2),\n with_=re.sub(r\'within \\d+ days\', \'within 45 days\', m.group(2))))\n plan = Plan(author="Pi", operations=ops)\n review = await docx_review("contract.docx", plan)\n if review is not None:\n for edit in review.edits:\n print(edit["index"], edit["context"])\n # ...later python call...\n await docx_commit(review.commit_key)\n\n`select` must match the paragraph\'s current visible text exactly; on a document\nwith pending tracked changes, grep the `view="final"` projection instead.\n\n## Example\n\n original = (await docx_read("contract.docx")).markup\n plan = Plan(author="Pi", change_mode="track", operations=[\n ReplaceText(at="2673269E", select="thirty days", with_="sixty days"),\n FormatText(at="2673269E", select="sixty days", bold=True),\n ])\n review = await docx_review("contract.docx", plan)\n # Inspect review.edits before committing in a later Python call.\n if review is not None:\n print(review.commit_key)\n\n\nUse await quote_find(source, query) to obtain the paragraph id and exact source text.\nCreate Quote(source, at, select, occurrence=1, style="double"), where select is one contiguous,\nverbatim source span. Adapt it with .omit(exact_source_text) and\n.bracket(exact_source_text, replacement). Adaptation selectors always refer to\nthe original span; overlaps fail. Finish with await quote_validate(q). Only the host\nconstructs QuoteResult.text, including curly double quotation marks by default\n(style="single" or style="none" are also available). Its provenance binds the source SHA-256, paragraph\nSHA-256, paragraph id, offsets, original span, and every adaptation.\n\nRaw quotation marks in ordinary authored text are lint errors. For a\nnon-quotational mention such as the word quotation, use\nawait term_render(Term("quotation")); its quote marks are host-created and its\nprovenance is classified as kind="term" rather than a source quotation.\nFor authored document content, prefer Inline([plain_text, Term(...), Quote(...)]).\nawait inline_validate() automatically lints every plain-text part before the host\nrenders structured nodes. The default policy is error; comment must be chosen\nexplicitly and returns warning spans that require protected comment bubbles.\nIn the plan REPL\'s flagged quotation mode, pass Quote, Term, or Inline directly\nto an operation with_ field. docx_review resolves the node and binds its source;\ncopying QuoteResult.text into a plain string loses that provenance boundary.\n\nExample:\nq = Quote("source.docx", at="89ABCDEF", select="The tenant must promptly pay the charge.")\nq.omit(" promptly")\nq.bracket("tenant", "Tenant")\nvalidated = await quote_validate(q)\nprint(validated.text)\nprint(validated.provenance)\n'
3
+ REFERENCE = '\n# docxdriver Python REPL API — Version 1 (Python-authored plan)\n\n# BEGIN GENERATED OPERATION DOCS\n## Operations\n\nSelected from the core schema (`$defs.editOp.oneOf`) through the\ncapability manifest; field shapes, types, and enums are exactly the\nchecked-in schema’s. Every op dataclass has `to_dict()`, omitting\noptional fields whose value is `None`; constructors use `with_`/`as_`\nfor Python keyword collisions and `to_dict()` maps back to the schema\nfield names. Operations are addressed by the paragraph `id` attribute\nfrom `docx_read` (uppercase eight-digit hex) or a `$name` alias.\n\n### ClearFooter\n\n`ClearFooter(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit footer reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### ClearHeader\n\n`ClearHeader(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit header reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### CommentAdd\n\n`CommentAdd(at: str, text: str, occurrence: int | None = None, select: str | None = None)`\n\nAnchor a comment with `text` to the paragraph at `at`; omit `select` to comment the whole paragraph, or pass `select`/`occurrence` (1-based) to pin one exact span.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `text` | `str` | yes |\n| `occurrence` | `int | None` | no |\n| `select` | `str | None` | no |\n\n### CommentDelete\n\n`CommentDelete(comment_id: str)`\n\nDelete a comment by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n\n### CommentReply\n\n`CommentReply(comment_id: str, text: str)`\n\nReply to an existing comment; `comment_id` is the decimal id from a prior `comment_add` or a comments-kind read.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `text` | `str` | yes |\n\n### CommentSetStatus\n\n`CommentSetStatus(comment_id: str, status: str)`\n\nSet a comment\'s status to `open` or `resolved` by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `status` | `str` | yes |\n\n### DeleteEquation\n\n`DeleteEquation(at: str, equation: int | None = None)`\n\nDelete a current equation while preserving the paragraph. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Tracked mode keeps the equation recoverable until the deletion is accepted.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `equation` | `int | None` | no |\n\n### DeleteParagraphs\n\n`DeleteParagraphs(at: list)`\n\nDelete the paragraphs listed in `at` (tracked deletion by default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n\n### EditTable\n\n`EditTable(action: dict, at: str)`\n\nDirect structural table edits using a source-bound table handle. action.kind is insert_rows/delete_rows/insert_columns/delete_columns/merge_cells/split_cell/delete; coordinates and counts are one-based. Insert/delete grid operations and merge require an unmerged rectangular grid. Split keeps content in the first resulting cell; merge concatenates content in row-major order. Rejects tracked mode and tables containing unresolved revisions, bookmarks, comments, fields or content controls. Delete the table instead of its final row or column.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `dict` | yes |\n| `at` | `str` | yes |\n\n### FormatParagraph\n\n`FormatParagraph(at: str, alignment: str | None = None, clear: list | None = None, first_line_indent: float | None = None, indent_left: float | None = None, indent_right: float | None = None, line_spacing: dict | None = None, space_after: float | None = None, space_before: float | None = None, style: str | None = None)`\n\nApply paragraph formatting to the paragraph at `at`: `style`, `alignment` (left/center/right/justify), signed `indent_left`/`indent_right`, signed `first_line_indent` (negative for hanging, zero for an explicit zero offset), `space_before`/`space_after`, `line_spacing` (dict with `mode` multiple/exact/at_least and `value`), or `clear`. Omitted properties are untouched; clear removes direct settings and inherits styles/defaults, winning over set. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `alignment` | `str | None` | no |\n| `clear` | `list | None` | no |\n| `first_line_indent` | `float | None` | no |\n| `indent_left` | `float | None` | no |\n| `indent_right` | `float | None` | no |\n| `line_spacing` | `dict | None` | no |\n| `space_after` | `float | None` | no |\n| `space_before` | `float | None` | no |\n| `style` | `str | None` | no |\n\n### FormatText\n\n`FormatText(at: str, select: str, bold: bool | None = None, clear: list | None = None, color: str | None = None, font_size: float | None = None, highlight: str | None = None, italic: bool | None = None, occurrence: int | None = None, strike: bool | None = None, subscript: bool | None = None, superscript: bool | None = None, underline: bool | None = None)`\n\nApply character formatting to an exact occurrence of `select` in the paragraph at `at`: boolean attributes (`bold`, `italic`, `underline`, `strike`, `superscript`, `subscript`), `color` (#RRGGBB), `highlight` (Word palette including none), `font_size` (points), `occurrence`, or `clear`. Omitted properties preserve source OOXML; false explicitly overrides inherited formatting (baseline for superscript/subscript). Clear removes the direct property and inherits styles/defaults, winning over set; superscript/subscript share one property. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `bold` | `bool | None` | no |\n| `clear` | `list | None` | no |\n| `color` | `str | None` | no |\n| `font_size` | `float | None` | no |\n| `highlight` | `str | None` | no |\n| `italic` | `bool | None` | no |\n| `occurrence` | `int | None` | no |\n| `strike` | `bool | None` | no |\n| `subscript` | `bool | None` | no |\n| `superscript` | `bool | None` | no |\n| `underline` | `bool | None` | no |\n\n### InsertParagraph\n\n`InsertParagraph(at: str, position: str, as_: str | None = None, style: str | None = None, with_: str | Inline | Quote | Term | None = None)`\n\nInsert a new paragraph before or after an anchor: paragraph id/alias (`at`) or body-level table number (`table:N` from markup); `with` is its text, `style` its style, and `as` names the new paragraph id (a `$name` alias).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `position` | `str` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n| `style` | `str | None` | no |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term | None` | no |\n\n### InsertTable\n\n`InsertTable(at: str, columns: int, position: str, rows: int, as_: str | None = None)`\n\nInsert a rectangular table before/after paragraph at, with rows and columns in 1..100. Direct mode only. Optional as creates a plan-local table alias for later formatting and structural edits.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `columns` | `int` | yes |\n| `position` | `str` | yes |\n| `rows` | `int` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n\n### ReplaceEquation\n\n`ReplaceEquation(at: str, mathml: str, display: bool | None = None, equation: int | None = None)`\n\nReplace a current equation using presentation MathML. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Display mode is preserved by default. Settle existing revisions before editing their equations.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `mathml` | `str` | yes |\n| `display` | `bool | None` | no |\n| `equation` | `int | None` | no |\n\n### ReplaceParagraph\n\n`ReplaceParagraph(at: str, with_: str | Inline | Quote | Term)`\n\nReplace the entire paragraph at `at` with `with`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n\n### ReplaceText\n\n`ReplaceText(at: str, select: str, with_: str | Inline | Quote | Term, occurrence: int | None = None)`\n\nReplace an exact occurrence of `select` in the paragraph at `at` with `with`; `occurrence` picks the nth match (1-based).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `occurrence` | `int | None` | no |\n\n### ReviseText\n\n`ReviseText(edits: list, at: str | None = None, occurrence: int | None = None, within: str | None = None)`\n\nSeveral exact replacements inside one bounded scope; prefer the chainable `Revise(within, at=None, occurrence=None).replace(select, with_, occurrence=None, all=False)` builder. Scope: `within` (a phrase unique in paragraph `at`, or in the whole document when `at` is omitted; `occurrence` picks a repeated one), else the paragraph `at`, else the whole document. Each edit must match exactly once in the scope unless it sets `occurrence` or `all`. All edits resolve against the scope as it stands when the operation starts, must not overlap, and become separate minimal tracked changes.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `edits` | `list` | yes |\n| `at` | `str | None` | no |\n| `occurrence` | `int | None` | no |\n| `within` | `str | None` | no |\n\n### RevisionSettle\n\n`RevisionSettle(action: str, target: str)`\n\nAccept or reject tracked changes: `target` is a decimal revision id from `await docx_read(path, kind="revisions")` or `all`; `action` is `accept` or `reject`. Prefer the `AcceptRevision(target)` / `RejectRevision(target)` shorthands. Settling `all` also settles tracked changes made by earlier operations in the same plan, and later selectors address the settled text.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `str` | yes |\n| `target` | `str` | yes |\n\n### SetEvenAndOddHeaders\n\n`SetEvenAndOddHeaders(even_and_odd: bool)`\n\nEnable or disable distinct even-page headers/footers (`even_and_odd`); when true, `kind="even"` chrome applies to even pages.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `even_and_odd` | `bool` | yes |\n\n### SetFirstPageMode\n\n`SetFirstPageMode(enabled: bool, section: int | None = None)`\n\nEnable/disable first-page stories in a section (default 1) without removing stored stories. Tracked mode records a section-property snapshot; settle pending section changes before editing again.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `enabled` | `bool` | yes |\n| `section` | `int | None` | no |\n\n### SetFooter\n\n`SetFooter(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section footer chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetHeader\n\n`SetHeader(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section header chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetList\n\n`SetList(at: list, format: str | None = None, level: int | None = None, levels: list | None = None, num_id: int | None = None, remove: bool | None = None, start: int | None = None)`\n\nAssign explicitly selected paragraphs to a new list (format defaults to decimal), continue an existing num_id, or remove membership with remove=True. level is 0..8; start is positive and creates an isolated restarted instance. Optional levels defines 1..9 per-level format/label/start/restart_after, signed indent_left/indent_right/first_line_indent and marker_bold/marker_italic/marker_font/marker_color for a new list. Label placeholders reference this or ancestor levels (%1..%9). Existing definitions are never restyled. Membership changes are tracked; settle pending paragraph changes first. Definitions remain after rejection for safe reference restoration.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n| `format` | `str | None` | no |\n| `level` | `int | None` | no |\n| `levels` | `list | None` | no |\n| `num_id` | `int | None` | no |\n| `remove` | `bool | None` | no |\n| `start` | `int | None` | no |\n\n### SetPageMargins\n\n`SetPageMargins(bottom: float | None = None, footer: float | None = None, gutter: float | None = None, header: float | None = None, left: float | None = None, right: float | None = None, section: int | None = None, top: float | None = None)`\n\nSet selected nonnegative page margins in points for a section (default 1): top/right/bottom/left/header/footer/gutter. Omitted values are preserved. Like existing header/footer setters, margin changes are direct even in tracked plans.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `bottom` | `float | None` | no |\n| `footer` | `float | None` | no |\n| `gutter` | `float | None` | no |\n| `header` | `float | None` | no |\n| `left` | `float | None` | no |\n| `right` | `float | None` | no |\n| `section` | `int | None` | no |\n| `top` | `float | None` | no |\n\n### SetSectionLayout\n\n`SetSectionLayout(break_type: str | None = None, column_separator: bool | None = None, column_spacing: float | None = None, column_widths: list | None = None, columns: int | None = None, height: float | None = None, orientation: str | None = None, page_number_continue: bool | None = None, page_number_format: str | None = None, page_number_start: int | None = None, page_size: str | None = None, section: int | None = None, width: float | None = None)`\n\nSelectively set section layout (section defaults to 1): page_size letter/legal/a4/a3/a5 or paired width/height in points; orientation portrait/landscape normalizes dimensions. Columns 1..45 with column_spacing, column_widths and column_separator; break_type nextPage/continuous/oddPage/evenPage/nextColumn; page_number_format, page_number_start or page_number_continue=true. Omission preserves properties; tracking snapshots section properties while preserving story relationships. Pending property changes must be settled first.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `break_type` | `str | None` | no |\n| `column_separator` | `bool | None` | no |\n| `column_spacing` | `float | None` | no |\n| `column_widths` | `list | None` | no |\n| `columns` | `int | None` | no |\n| `height` | `float | None` | no |\n| `orientation` | `str | None` | no |\n| `page_number_continue` | `bool | None` | no |\n| `page_number_format` | `str | None` | no |\n| `page_number_start` | `int | None` | no |\n| `page_size` | `str | None` | no |\n| `section` | `int | None` | no |\n| `width` | `float | None` | no |\n\n# END GENERATED OPERATION DOCS\n\n## Scoped revisions\n\n`Revise(within=None, at=None, occurrence=None)` builds one `ReviseText`\noperation; chain `.replace(select, with_, occurrence=None, all=False)` for each\nchange. The scope bounds every `select`, so short selectors stay unambiguous\nin long documents and each change becomes a minimal tracked change:\n\n # unique phrase, no paragraph id needed:\n Revise("the tenant shall pay rent within thirty days").replace("thirty", "sixty").replace("rent", "the rent")\n Revise(at="2673269E").replace("Tenant", "Lessee", all=True) # whole paragraph\n Revise("Section 4(b)", at="2673269E", occurrence=2).replace("4(b)", "4(c)")\n Revise().replace("Acme Corp.", "Acme, Inc.", all=True) # whole document\n\n`within` must occur exactly once in its paragraph (`at`) or document unless\n`occurrence` picks one. Each `select` must occur exactly once in the scope\nunless it passes `occurrence` or `all=True`. All selectors resolve against the\nscope as it stands when the operation starts, so later replacements never see\nearlier ones; overlapping selections are rejected.\n\n`AcceptRevision(target)` and `RejectRevision(target)` build `RevisionSettle`\noperations; `target` is a revision id from `await docx_read(path, kind="revisions")`\nor `"all"`. Put settle operations first: `"all"` also settles tracked changes\nmade earlier in the same plan.\n\nEvery operation dataclass has `to_dict()`, omitting optional fields whose\nvalue is `None`; `Plan.to_dict()` returns `{"author": ..., "change_mode": ..., "ops": [...]}`.\n\nTracked plain-text replacements automatically compute word-level redlines in\nReplaceText, ReviseText, and ReplaceParagraph. Unchanged words retain their\nformatting; adjacent changed words form one deletion followed by one insertion.\nRich replacements retain whole-selection tracking to apply their formatting.\n\n## Host functions\n\n- `await docx_create(path: str, html: str)`\n- `await docx_read(path: str, view: str = "markup", kind: str = "document") -> ReadResult | dict` — complete HTML\n markup and a current equations-address listing (at, equation, display, editable) by default (`kind="document"`); `kind="comments"`, `"styles"`, `"revisions"`, or `"assets"` returns the\n core listing object (and a second positional of `comments`/`styles`/`revisions` is treated as `kind`).\n- `await docx_find(path: str, query: str, ignore_case: bool = False) -> list` — core find match\n records verbatim (`{index, id?, text, style?, number?}` per matching paragraph; `id` is\n the canonical uppercase paragraph address)\n- `await docx_help(topic: str | None = None) -> str`\n- `await docx_review(path: str, plan: Plan) -> ReviewResult | None` — validates the complete plan and returns `commit_key` plus bounded per-operation `edits` with affected markup context; returns `None` when validation is blocked\n- `await docx_commit(commit_key: str)` — commits the immutable reviewed plan; the key owns the\n target file and plan\nReview does not write the target. Review and commit must happen in separate later\nPython calls; changing the plan requires a new review.\n\n## Discovering paragraphs\n\n`docx_find` plus `docx_read` are the supported discovery workflow:\nfind returns core match records to locate paragraphs, and reads return\nstructural markup. Operations stay addressed\nby the markup-view paragraph `id`:\n\n matches = await docx_find("contract.docx", "Payment is due within")\n for m in matches:\n print(m["id"], m["text"])\n markup = (await docx_read("contract.docx")).markup\n\nReading `view="final"` or `view="original"` is allowed for analysis, but\nreview plans remain source-bound to the exact bytes and paragraph ids of the\ncurrent `markup` view.\n\n## Computing operations with regex\n\n`re` is pre-imported and `ReadResult.markup` is an ordinary string, so you can\ninspect it and build operations in Python loops. Return the full ReadResult or\nprint its full markup and semantic metadata when reading; prefer complete document\ncontext. Named DOCX style IDs appear in data-docx-style and map one-to-one to\nReadResult.styles (resolved CSS keyed by style ID). Presentation rules appear\nin data-docx-css and map to ReadResult.css. Both catalogs are omitted from the\ndefault display; inspect result.styles or result.css to read their definitions;\nwhen authoring a fragment, use equivalent inline formatting rather than copying\nan unresolved class. Image aliases map to canonical assets in ReadResult.assets.\nReadResult.selection_space describes comment selectors; ReadResult.selections\nprovides exact text for paragraphs where generated content or revisions make\nHTML text ambiguous:\n\n\n original = (await docx_read("contract.docx")).markup\n ops = []\n for m in re.finditer(r\'<p id="([0-9A-F]{8})"[^>]*>([^<]*?)</p>\', original):\n if "Payment is due within" in m.group(2):\n ops.append(ReplaceText(at=m.group(1), select=m.group(2),\n with_=re.sub(r\'within \\d+ days\', \'within 45 days\', m.group(2))))\n plan = Plan(author="Pi", operations=ops)\n review = await docx_review("contract.docx", plan)\n if review is not None:\n for edit in review.edits:\n print(edit["index"], edit["context"])\n # ...later python call...\n await docx_commit(review.commit_key)\n\n`select` must match the paragraph\'s current visible text exactly; on a document\nwith pending tracked changes, grep the `view="final"` projection instead.\n\n## Example\n\n original = (await docx_read("contract.docx")).markup\n plan = Plan(author="Pi", change_mode="track", operations=[\n ReplaceText(at="2673269E", select="thirty days", with_="sixty days"),\n FormatText(at="2673269E", select="sixty days", bold=True),\n ])\n review = await docx_review("contract.docx", plan)\n # Inspect review.edits before committing in a later Python call.\n if review is not None:\n print(review.commit_key)\n\n\nUse await quote_find(source, query) to obtain the paragraph id and exact source text.\nCreate Quote(source, at, select, occurrence=1, style="double"), where select is one contiguous,\nverbatim source span. Adapt it with .omit(exact_source_text) and\n.bracket(exact_source_text, replacement). Adaptation selectors always refer to\nthe original span; overlaps fail. Finish with await quote_validate(q). Only the host\nconstructs QuoteResult.text, including curly double quotation marks by default\n(style="single" or style="none" are also available). Its provenance binds the source SHA-256, paragraph\nSHA-256, paragraph id, offsets, original span, and every adaptation.\n\nRaw quotation marks in ordinary authored text are lint errors. For a\nnon-quotational mention such as the word quotation, use\nawait term_render(Term("quotation")); its quote marks are host-created and its\nprovenance is classified as kind="term" rather than a source quotation.\nFor authored document content, prefer Inline([plain_text, Term(...), Quote(...)]).\nawait inline_validate() automatically lints every plain-text part before the host\nrenders structured nodes. The default policy is error; comment must be chosen\nexplicitly and returns warning spans that require protected comment bubbles.\nIn the plan REPL\'s flagged quotation mode, pass Quote, Term, or Inline directly\nto an operation with_ field. docx_review resolves the node and binds its source;\ncopying QuoteResult.text into a plain string loses that provenance boundary.\n\nExample:\nq = Quote("source.docx", at="89ABCDEF", select="The tenant must promptly pay the charge.")\nq.omit(" promptly")\nq.bracket("tenant", "Tenant")\nvalidated = await quote_validate(q)\nprint(validated.text)\nprint(validated.provenance)\n'
@@ -1,3 +1,3 @@
1
1
  # Generated by scripts/generate-docxdriver-repl-prelude.py; edit the shared Pi templates.
2
2
  PRELUDE = "from dataclasses import dataclass\n\n@dataclass\nclass QuoteResult:\n text: str\n provenance: dict\n\n@dataclass\nclass TermResult:\n text: str\n provenance: dict\n\nclass Term:\n\n def __init__(self, text: str, style: str='double'):\n self.text = text\n self.style = style\n\n def to_dict(self):\n return {'$kind': 'term', 'text': self.text, 'style': self.style}\n\n@dataclass\nclass InlineResult:\n text: str\n audit: dict\n\nclass Inline:\n\n def __init__(self, parts: list):\n self.parts = parts\n\n def to_dict(self):\n return {'$kind': 'inline', 'parts': [part.to_dict() if hasattr(part, 'to_dict') else part for part in self.parts]}\n\nclass Quote:\n\n def __init__(self, source: str, at: str, select: str, occurrence: int=1, style: str='double'):\n self.source = source\n self.at = at\n self.select = select\n self.occurrence = occurrence\n self.style = style\n self.changes = []\n\n def omit(self, select: str, occurrence: int=1, marker: str='…'):\n self.changes.append({'kind': 'omit', 'select': select, 'occurrence': occurrence, 'marker': marker})\n return self\n\n def bracket(self, select: str, replacement: str, occurrence: int=1):\n self.changes.append({'kind': 'bracket', 'select': select, 'replacement': replacement, 'occurrence': occurrence})\n return self\n\n def to_dict(self):\n return {'$kind': 'quote', 'source': self.source, 'at': self.at, 'select': self.select, 'occurrence': self.occurrence, 'style': self.style, 'changes': self.changes}\n\nasync def quote_find(source: str, query: str, ignore_case: bool=False) -> list:\n return await _quote_find(source, query, ignore_case)\n\nasync def quote_validate(quote: Quote) -> QuoteResult:\n result = await _quote_validate(quote.to_dict())\n return QuoteResult(text=result['text'], provenance=result)\n\nasync def quote_lint(text: str, severity: str='error') -> list:\n return await _quote_lint(text, severity)\n\nasync def term_render(term: Term) -> TermResult:\n result = await _term_render(term.to_dict())\n return TermResult(text=result['text'], provenance=result)\n\nasync def inline_validate(inline: Inline, policy: str='error') -> InlineResult:\n result = await _inline_validate(inline.to_dict(), policy)\n return InlineResult(text=result['text'], audit=result['audit'])\nfrom dataclasses import dataclass\nimport re\n\n@dataclass\nclass ClearFooter:\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'clear_footer'}\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass ClearHeader:\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'clear_header'}\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass CommentAdd:\n at: str\n text: str\n occurrence: int | None = None\n select: str | None = None\n\n def to_dict(self):\n value = {'op': 'comment_add'}\n if self.at is not None:\n value['at'] = self.at\n if self.text is not None:\n value['text'] = self.text\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n if self.select is not None:\n value['select'] = self.select\n return value\n\n@dataclass\nclass CommentDelete:\n comment_id: str\n\n def to_dict(self):\n value = {'op': 'comment_delete'}\n if self.comment_id is not None:\n value['comment_id'] = self.comment_id\n return value\n\n@dataclass\nclass CommentReply:\n comment_id: str\n text: str\n\n def to_dict(self):\n value = {'op': 'comment_reply'}\n if self.comment_id is not None:\n value['comment_id'] = self.comment_id\n if self.text is not None:\n value['text'] = self.text\n return value\n\n@dataclass\nclass CommentSetStatus:\n comment_id: str\n status: str\n\n def to_dict(self):\n value = {'op': 'comment_set_status'}\n if self.comment_id is not None:\n value['comment_id'] = self.comment_id\n if self.status is not None:\n value['status'] = self.status\n return value\n\n@dataclass\nclass DeleteEquation:\n at: str\n equation: int | None = None\n\n def to_dict(self):\n value = {'op': 'delete_equation'}\n if self.at is not None:\n value['at'] = self.at\n if self.equation is not None:\n value['equation'] = self.equation\n return value\n\n@dataclass\nclass DeleteParagraphs:\n at: list\n\n def to_dict(self):\n value = {'op': 'delete_paragraphs'}\n if self.at is not None:\n value['at'] = self.at\n return value\n\n@dataclass\nclass EditTable:\n action: dict\n at: str\n\n def to_dict(self):\n value = {'op': 'edit_table'}\n if self.action is not None:\n value['action'] = self.action\n if self.at is not None:\n value['at'] = self.at\n return value\n\n@dataclass\nclass FormatParagraph:\n at: str\n alignment: str | None = None\n clear: list | None = None\n first_line_indent: float | None = None\n indent_left: float | None = None\n indent_right: float | None = None\n line_spacing: dict | None = None\n space_after: float | None = None\n space_before: float | None = None\n style: str | None = None\n\n def to_dict(self):\n value = {'op': 'format_paragraph'}\n if self.at is not None:\n value['at'] = self.at\n if self.alignment is not None:\n value['alignment'] = self.alignment\n if self.clear is not None:\n value['clear'] = self.clear\n if self.first_line_indent is not None:\n value['first_line_indent'] = self.first_line_indent\n if self.indent_left is not None:\n value['indent_left'] = self.indent_left\n if self.indent_right is not None:\n value['indent_right'] = self.indent_right\n if self.line_spacing is not None:\n value['line_spacing'] = self.line_spacing\n if self.space_after is not None:\n value['space_after'] = self.space_after\n if self.space_before is not None:\n value['space_before'] = self.space_before\n if self.style is not None:\n value['style'] = self.style\n return value\n\n@dataclass\nclass FormatText:\n at: str\n select: str\n bold: bool | None = None\n clear: list | None = None\n color: str | None = None\n font_size: float | None = None\n highlight: str | None = None\n italic: bool | None = None\n occurrence: int | None = None\n strike: bool | None = None\n subscript: bool | None = None\n superscript: bool | None = None\n underline: bool | None = None\n\n def to_dict(self):\n value = {'op': 'format_text'}\n if self.at is not None:\n value['at'] = self.at\n if self.select is not None:\n value['select'] = self.select\n if self.bold is not None:\n value['bold'] = self.bold\n if self.clear is not None:\n value['clear'] = self.clear\n if self.color is not None:\n value['color'] = self.color\n if self.font_size is not None:\n value['font_size'] = self.font_size\n if self.highlight is not None:\n value['highlight'] = self.highlight\n if self.italic is not None:\n value['italic'] = self.italic\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n if self.strike is not None:\n value['strike'] = self.strike\n if self.subscript is not None:\n value['subscript'] = self.subscript\n if self.superscript is not None:\n value['superscript'] = self.superscript\n if self.underline is not None:\n value['underline'] = self.underline\n return value\n\n@dataclass\nclass InsertParagraph:\n at: str\n position: str\n as_: str | None = None\n style: str | None = None\n with_: str | Inline | Quote | Term | None = None\n\n def to_dict(self):\n value = {'op': 'insert_paragraph'}\n if self.at is not None:\n value['at'] = self.at\n if self.position is not None:\n value['position'] = self.position\n if self.as_ is not None:\n value['as'] = self.as_\n if self.style is not None:\n value['style'] = self.style\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n return value\n\n@dataclass\nclass InsertTable:\n at: str\n columns: int\n position: str\n rows: int\n as_: str | None = None\n\n def to_dict(self):\n value = {'op': 'insert_table'}\n if self.at is not None:\n value['at'] = self.at\n if self.columns is not None:\n value['columns'] = self.columns\n if self.position is not None:\n value['position'] = self.position\n if self.rows is not None:\n value['rows'] = self.rows\n if self.as_ is not None:\n value['as'] = self.as_\n return value\n\n@dataclass\nclass ReplaceEquation:\n at: str\n mathml: str\n display: bool | None = None\n equation: int | None = None\n\n def to_dict(self):\n value = {'op': 'replace_equation'}\n if self.at is not None:\n value['at'] = self.at\n if self.mathml is not None:\n value['mathml'] = self.mathml\n if self.display is not None:\n value['display'] = self.display\n if self.equation is not None:\n value['equation'] = self.equation\n return value\n\n@dataclass\nclass ReplaceParagraph:\n at: str\n with_: str | Inline | Quote | Term\n\n def to_dict(self):\n value = {'op': 'replace_paragraph'}\n if self.at is not None:\n value['at'] = self.at\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n return value\n\n@dataclass\nclass ReplaceText:\n at: str\n select: str\n with_: str | Inline | Quote | Term\n occurrence: int | None = None\n\n def to_dict(self):\n value = {'op': 'replace_text'}\n if self.at is not None:\n value['at'] = self.at\n if self.select is not None:\n value['select'] = self.select\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n return value\n\n@dataclass\nclass ReviseText:\n edits: list\n at: str | None = None\n occurrence: int | None = None\n within: str | None = None\n\n def to_dict(self):\n value = {'op': 'revise_text'}\n if self.edits is not None:\n value['edits'] = self.edits\n if self.at is not None:\n value['at'] = self.at\n if self.occurrence is not None:\n value['occurrence'] = self.occurrence\n if self.within is not None:\n value['within'] = self.within\n return value\n\n@dataclass\nclass RevisionSettle:\n action: str\n target: str\n\n def to_dict(self):\n value = {'op': 'revision_settle'}\n if self.action is not None:\n value['action'] = self.action\n if self.target is not None:\n value['target'] = self.target\n return value\n\n@dataclass\nclass SetEvenAndOddHeaders:\n even_and_odd: bool\n\n def to_dict(self):\n value = {'op': 'set_even_and_odd_headers'}\n if self.even_and_odd is not None:\n value['even_and_odd'] = self.even_and_odd\n return value\n\n@dataclass\nclass SetFirstPageMode:\n enabled: bool\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_first_page_mode'}\n if self.enabled is not None:\n value['enabled'] = self.enabled\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass SetFooter:\n with_: str | Inline | Quote | Term\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_footer'}\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass SetHeader:\n with_: str | Inline | Quote | Term\n kind: str | None = None\n section: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_header'}\n if self.with_ is not None:\n value['with'] = self.with_.to_dict() if hasattr(self.with_, 'to_dict') else self.with_\n if self.kind is not None:\n value['kind'] = self.kind\n if self.section is not None:\n value['section'] = self.section\n return value\n\n@dataclass\nclass SetList:\n at: list\n format: str | None = None\n level: int | None = None\n levels: list | None = None\n num_id: int | None = None\n remove: bool | None = None\n start: int | None = None\n\n def to_dict(self):\n value = {'op': 'set_list'}\n if self.at is not None:\n value['at'] = self.at\n if self.format is not None:\n value['format'] = self.format\n if self.level is not None:\n value['level'] = self.level\n if self.levels is not None:\n value['levels'] = self.levels\n if self.num_id is not None:\n value['num_id'] = self.num_id\n if self.remove is not None:\n value['remove'] = self.remove\n if self.start is not None:\n value['start'] = self.start\n return value\n\n@dataclass\nclass SetPageMargins:\n bottom: float | None = None\n footer: float | None = None\n gutter: float | None = None\n header: float | None = None\n left: float | None = None\n right: float | None = None\n section: int | None = None\n top: float | None = None\n\n def to_dict(self):\n value = {'op': 'set_page_margins'}\n if self.bottom is not None:\n value['bottom'] = self.bottom\n if self.footer is not None:\n value['footer'] = self.footer\n if self.gutter is not None:\n value['gutter'] = self.gutter\n if self.header is not None:\n value['header'] = self.header\n if self.left is not None:\n value['left'] = self.left\n if self.right is not None:\n value['right'] = self.right\n if self.section is not None:\n value['section'] = self.section\n if self.top is not None:\n value['top'] = self.top\n return value\n\n@dataclass\nclass SetSectionLayout:\n break_type: str | None = None\n column_separator: bool | None = None\n column_spacing: float | None = None\n column_widths: list | None = None\n columns: int | None = None\n height: float | None = None\n orientation: str | None = None\n page_number_continue: bool | None = None\n page_number_format: str | None = None\n page_number_start: int | None = None\n page_size: str | None = None\n section: int | None = None\n width: float | None = None\n\n def to_dict(self):\n value = {'op': 'set_section_layout'}\n if self.break_type is not None:\n value['break_type'] = self.break_type\n if self.column_separator is not None:\n value['column_separator'] = self.column_separator\n if self.column_spacing is not None:\n value['column_spacing'] = self.column_spacing\n if self.column_widths is not None:\n value['column_widths'] = self.column_widths\n if self.columns is not None:\n value['columns'] = self.columns\n if self.height is not None:\n value['height'] = self.height\n if self.orientation is not None:\n value['orientation'] = self.orientation\n if self.page_number_continue is not None:\n value['page_number_continue'] = self.page_number_continue\n if self.page_number_format is not None:\n value['page_number_format'] = self.page_number_format\n if self.page_number_start is not None:\n value['page_number_start'] = self.page_number_start\n if self.page_size is not None:\n value['page_size'] = self.page_size\n if self.section is not None:\n value['section'] = self.section\n if self.width is not None:\n value['width'] = self.width\n return value\n\n@dataclass\nclass Revise:\n within: str | None = None\n at: str | None = None\n occurrence: int | None = None\n edits: list = None\n\n def replace(self, select: str, with_, occurrence: int | None=None, all: bool=False):\n edit = {'select': select, 'with': with_.to_dict() if hasattr(with_, 'to_dict') else with_}\n if occurrence is not None:\n edit['occurrence'] = occurrence\n if all:\n edit['all'] = True\n self.edits = (self.edits or []) + [edit]\n return self\n\n def to_dict(self):\n return ReviseText(edits=self.edits or [], at=self.at, occurrence=self.occurrence, within=self.within).to_dict()\n\ndef AcceptRevision(target: str) -> RevisionSettle:\n return RevisionSettle(action='accept', target=str(target))\n\ndef RejectRevision(target: str) -> RevisionSettle:\n return RevisionSettle(action='reject', target=str(target))\n\n@dataclass\nclass ReviewResult:\n commit_key: str\n edits: list\n\n@dataclass\nclass ReadResult:\n markup: str\n equations: list = None\n selection_space: dict = None\n selections: dict = None\n comments: list = None\n assets: list = None\n comments_error: str = None\n styles: dict = None\n css: dict = None\n\n def __repr__(self):\n return 'ReadResult(markup=' + repr(self.markup) + ', styles=' + str(len(self.styles or {})) + ' definitions, css=' + str(len(self.css or {})) + ' rules; inspect .styles and .css, equations=' + repr(self.equations) + ', selection_space=' + repr(self.selection_space) + ', selections=' + repr(self.selections) + ', comments=' + repr(self.comments) + ', assets=' + repr(self.assets) + ', comments_error=' + repr(self.comments_error) + ')'\n\n@dataclass\nclass Plan:\n author: str = 'docxdriver'\n change_mode: str = 'track'\n operations: list = None\n\n def to_dict(self):\n ops = self.operations if self.operations is not None else []\n return {'author': self.author, 'change_mode': self.change_mode, 'ops': [op.to_dict() if hasattr(op, 'to_dict') else op for op in ops]}\n\nasync def docx_create(path: str, html: str):\n return await _docx_create(path, html)\n\nasync def docx_read(path: str, view: str='markup', kind: str='document'):\n _listing_kinds = ('comments', 'styles', 'revisions', 'assets')\n if kind == 'document' and view in _listing_kinds:\n kind = view\n if kind in _listing_kinds:\n return await _docx_read(path, view, kind)\n if kind != 'document':\n raise ValueError('kind must be one of document, comments, styles, revisions, assets')\n result = await _docx_read(path, view)\n return ReadResult(markup=result['markup'], styles=result.get('styles', {}), css=result.get('css', {}), equations=result.get('equations', []), selection_space=result.get('selection_space'), selections=result.get('selections'), comments=result.get('comments', []), assets=result.get('assets', []), comments_error=result.get('comments_error'))\n\nasync def docx_find(path: str, query: str, ignore_case: bool=False) -> list:\n return await _docx_find(path, query, ignore_case)\n\nasync def docx_help(topic: str | None=None) -> str:\n return await _docx_help(topic)\n\nasync def docx_review(path: str, plan: Plan) -> ReviewResult | None:\n result = await _docx_review(path, {'plan': plan.to_dict()})\n if result is None:\n return None\n return ReviewResult(commit_key=result['commit_key'], edits=result['edits'])\n\nasync def docx_commit(commit_key: str):\n await _docx_commit(commit_key)\n return None"
3
- REFERENCE = '\n# docxdriver Python REPL API — Version 1 (Python-authored plan)\n\n# BEGIN GENERATED OPERATION DOCS\n## Operations\n\nSelected from the core schema (`$defs.editOp.oneOf`) through the\ncapability manifest; field shapes, types, and enums are exactly the\nchecked-in schema’s. Every op dataclass has `to_dict()`, omitting\noptional fields whose value is `None`; constructors use `with_`/`as_`\nfor Python keyword collisions and `to_dict()` maps back to the schema\nfield names. Operations are addressed by the paragraph `id` attribute\nfrom `docx_read` (uppercase eight-digit hex) or a `$name` alias.\n\n### ClearFooter\n\n`ClearFooter(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit footer reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### ClearHeader\n\n`ClearHeader(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit header reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### CommentAdd\n\n`CommentAdd(at: str, text: str, occurrence: int | None = None, select: str | None = None)`\n\nAnchor a comment with `text` to the paragraph at `at`; omit `select` to comment the whole paragraph, or pass `select`/`occurrence` (1-based) to pin one exact span.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `text` | `str` | yes |\n| `occurrence` | `int | None` | no |\n| `select` | `str | None` | no |\n\n### CommentDelete\n\n`CommentDelete(comment_id: str)`\n\nDelete a comment by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n\n### CommentReply\n\n`CommentReply(comment_id: str, text: str)`\n\nReply to an existing comment; `comment_id` is the decimal id from a prior `comment_add` or a comments-kind read.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `text` | `str` | yes |\n\n### CommentSetStatus\n\n`CommentSetStatus(comment_id: str, status: str)`\n\nSet a comment\'s status to `open` or `resolved` by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `status` | `str` | yes |\n\n### DeleteEquation\n\n`DeleteEquation(at: str, equation: int | None = None)`\n\nDelete a current equation while preserving the paragraph. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Tracked mode keeps the equation recoverable until the deletion is accepted.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `equation` | `int | None` | no |\n\n### DeleteParagraphs\n\n`DeleteParagraphs(at: list)`\n\nDelete the paragraphs listed in `at` (tracked deletion by default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n\n### EditTable\n\n`EditTable(action: dict, at: str)`\n\nDirect structural table edits using a source-bound table handle. action.kind is insert_rows/delete_rows/insert_columns/delete_columns/merge_cells/split_cell/delete; coordinates and counts are one-based. Insert/delete grid operations and merge require an unmerged rectangular grid. Split keeps content in the first resulting cell; merge concatenates content in row-major order. Rejects tracked mode and tables containing unresolved revisions, bookmarks, comments, fields or content controls. Delete the table instead of its final row or column.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `dict` | yes |\n| `at` | `str` | yes |\n\n### FormatParagraph\n\n`FormatParagraph(at: str, alignment: str | None = None, clear: list | None = None, first_line_indent: float | None = None, indent_left: float | None = None, indent_right: float | None = None, line_spacing: dict | None = None, space_after: float | None = None, space_before: float | None = None, style: str | None = None)`\n\nApply paragraph formatting to the paragraph at `at`: `style`, `alignment` (left/center/right/justify), signed `indent_left`/`indent_right`, signed `first_line_indent` (negative for hanging, zero for an explicit zero offset), `space_before`/`space_after`, `line_spacing` (dict with `mode` multiple/exact/at_least and `value`), or `clear`. Omitted properties are untouched; clear removes direct settings and inherits styles/defaults, winning over set. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `alignment` | `str | None` | no |\n| `clear` | `list | None` | no |\n| `first_line_indent` | `float | None` | no |\n| `indent_left` | `float | None` | no |\n| `indent_right` | `float | None` | no |\n| `line_spacing` | `dict | None` | no |\n| `space_after` | `float | None` | no |\n| `space_before` | `float | None` | no |\n| `style` | `str | None` | no |\n\n### FormatText\n\n`FormatText(at: str, select: str, bold: bool | None = None, clear: list | None = None, color: str | None = None, font_size: float | None = None, highlight: str | None = None, italic: bool | None = None, occurrence: int | None = None, strike: bool | None = None, subscript: bool | None = None, superscript: bool | None = None, underline: bool | None = None)`\n\nApply character formatting to an exact occurrence of `select` in the paragraph at `at`: boolean attributes (`bold`, `italic`, `underline`, `strike`, `superscript`, `subscript`), `color` (#RRGGBB), `highlight` (Word palette including none), `font_size` (points), `occurrence`, or `clear`. Omitted properties preserve source OOXML; false explicitly overrides inherited formatting (baseline for superscript/subscript). Clear removes the direct property and inherits styles/defaults, winning over set; superscript/subscript share one property. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `bold` | `bool | None` | no |\n| `clear` | `list | None` | no |\n| `color` | `str | None` | no |\n| `font_size` | `float | None` | no |\n| `highlight` | `str | None` | no |\n| `italic` | `bool | None` | no |\n| `occurrence` | `int | None` | no |\n| `strike` | `bool | None` | no |\n| `subscript` | `bool | None` | no |\n| `superscript` | `bool | None` | no |\n| `underline` | `bool | None` | no |\n\n### InsertParagraph\n\n`InsertParagraph(at: str, position: str, as_: str | None = None, style: str | None = None, with_: str | Inline | Quote | Term | None = None)`\n\nInsert a new paragraph before or after an anchor: paragraph id/alias (`at`) or body-level table number (`table:N` from markup); `with` is its text, `style` its style, and `as` names the new paragraph id (a `$name` alias).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `position` | `str` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n| `style` | `str | None` | no |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term | None` | no |\n\n### InsertTable\n\n`InsertTable(at: str, columns: int, position: str, rows: int, as_: str | None = None)`\n\nInsert a rectangular table before/after paragraph at, with rows and columns in 1..100. Direct mode only. Optional as creates a plan-local table alias for later formatting and structural edits.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `columns` | `int` | yes |\n| `position` | `str` | yes |\n| `rows` | `int` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n\n### ReplaceEquation\n\n`ReplaceEquation(at: str, mathml: str, display: bool | None = None, equation: int | None = None)`\n\nReplace a current equation using presentation MathML. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Display mode is preserved by default. Settle existing revisions before editing their equations.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `mathml` | `str` | yes |\n| `display` | `bool | None` | no |\n| `equation` | `int | None` | no |\n\n### ReplaceParagraph\n\n`ReplaceParagraph(at: str, with_: str | Inline | Quote | Term)`\n\nReplace the entire paragraph at `at` with `with`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n\n### ReplaceText\n\n`ReplaceText(at: str, select: str, with_: str | Inline | Quote | Term, occurrence: int | None = None)`\n\nReplace an exact occurrence of `select` in the paragraph at `at` with `with`; `occurrence` picks the nth match (1-based).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `occurrence` | `int | None` | no |\n\n### ReviseText\n\n`ReviseText(edits: list, at: str | None = None, occurrence: int | None = None, within: str | None = None)`\n\nSeveral exact replacements inside one bounded scope; prefer the chainable `Revise(within, at=None, occurrence=None).replace(select, with_, occurrence=None, all=False)` builder. Scope: `within` (a phrase unique in paragraph `at`, or in the whole document when `at` is omitted; `occurrence` picks a repeated one), else the paragraph `at`, else the whole document. Each edit must match exactly once in the scope unless it sets `occurrence` or `all`. All edits resolve against the scope as it stands when the operation starts, must not overlap, and become separate minimal tracked changes.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `edits` | `list` | yes |\n| `at` | `str | None` | no |\n| `occurrence` | `int | None` | no |\n| `within` | `str | None` | no |\n\n### RevisionSettle\n\n`RevisionSettle(action: str, target: str)`\n\nAccept or reject tracked changes: `target` is a decimal revision id from `await docx_read(path, kind="revisions")` or `all`; `action` is `accept` or `reject`. Prefer the `AcceptRevision(target)` / `RejectRevision(target)` shorthands. Settling `all` also settles tracked changes made by earlier operations in the same plan, and later selectors address the settled text.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `str` | yes |\n| `target` | `str` | yes |\n\n### SetEvenAndOddHeaders\n\n`SetEvenAndOddHeaders(even_and_odd: bool)`\n\nEnable or disable distinct even-page headers/footers (`even_and_odd`); when true, `kind="even"` chrome applies to even pages.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `even_and_odd` | `bool` | yes |\n\n### SetFirstPageMode\n\n`SetFirstPageMode(enabled: bool, section: int | None = None)`\n\nEnable/disable first-page stories in a section (default 1) without removing stored stories. Tracked mode records a section-property snapshot; settle pending section changes before editing again.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `enabled` | `bool` | yes |\n| `section` | `int | None` | no |\n\n### SetFooter\n\n`SetFooter(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section footer chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetHeader\n\n`SetHeader(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section header chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetList\n\n`SetList(at: list, format: str | None = None, level: int | None = None, levels: list | None = None, num_id: int | None = None, remove: bool | None = None, start: int | None = None)`\n\nAssign explicitly selected paragraphs to a new list (format defaults to decimal), continue an existing num_id, or remove membership with remove=True. level is 0..8; start is positive and creates an isolated restarted instance. Optional levels defines 1..9 per-level format/label/start/restart_after, signed indent_left/indent_right/first_line_indent and marker_bold/marker_italic/marker_font/marker_color for a new list. Label placeholders reference this or ancestor levels (%1..%9). Existing definitions are never restyled. Membership changes are tracked; settle pending paragraph changes first. Definitions remain after rejection for safe reference restoration.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n| `format` | `str | None` | no |\n| `level` | `int | None` | no |\n| `levels` | `list | None` | no |\n| `num_id` | `int | None` | no |\n| `remove` | `bool | None` | no |\n| `start` | `int | None` | no |\n\n### SetPageMargins\n\n`SetPageMargins(bottom: float | None = None, footer: float | None = None, gutter: float | None = None, header: float | None = None, left: float | None = None, right: float | None = None, section: int | None = None, top: float | None = None)`\n\nSet selected nonnegative page margins in points for a section (default 1): top/right/bottom/left/header/footer/gutter. Omitted values are preserved. Like existing header/footer setters, margin changes are direct even in tracked plans.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `bottom` | `float | None` | no |\n| `footer` | `float | None` | no |\n| `gutter` | `float | None` | no |\n| `header` | `float | None` | no |\n| `left` | `float | None` | no |\n| `right` | `float | None` | no |\n| `section` | `int | None` | no |\n| `top` | `float | None` | no |\n\n### SetSectionLayout\n\n`SetSectionLayout(break_type: str | None = None, column_separator: bool | None = None, column_spacing: float | None = None, column_widths: list | None = None, columns: int | None = None, height: float | None = None, orientation: str | None = None, page_number_continue: bool | None = None, page_number_format: str | None = None, page_number_start: int | None = None, page_size: str | None = None, section: int | None = None, width: float | None = None)`\n\nSelectively set section layout (section defaults to 1): page_size letter/legal/a4/a3/a5 or paired width/height in points; orientation portrait/landscape normalizes dimensions. Columns 1..45 with column_spacing, column_widths and column_separator; break_type nextPage/continuous/oddPage/evenPage/nextColumn; page_number_format, page_number_start or page_number_continue=true. Omission preserves properties; tracking snapshots section properties while preserving story relationships. Pending property changes must be settled first.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `break_type` | `str | None` | no |\n| `column_separator` | `bool | None` | no |\n| `column_spacing` | `float | None` | no |\n| `column_widths` | `list | None` | no |\n| `columns` | `int | None` | no |\n| `height` | `float | None` | no |\n| `orientation` | `str | None` | no |\n| `page_number_continue` | `bool | None` | no |\n| `page_number_format` | `str | None` | no |\n| `page_number_start` | `int | None` | no |\n| `page_size` | `str | None` | no |\n| `section` | `int | None` | no |\n| `width` | `float | None` | no |\n\n# END GENERATED OPERATION DOCS\n\n## Scoped revisions\n\n`Revise(within=None, at=None, occurrence=None)` builds one `ReviseText`\noperation; chain `.replace(select, with_, occurrence=None, all=False)` for each\nchange. The scope bounds every `select`, so short selectors stay unambiguous\nin long documents and each change becomes a minimal tracked change:\n\n # unique phrase, no paragraph id needed:\n Revise("the tenant shall pay rent within thirty days").replace("thirty", "sixty").replace("rent", "the rent")\n Revise(at="2673269E").replace("Tenant", "Lessee", all=True) # whole paragraph\n Revise("Section 4(b)", at="2673269E", occurrence=2).replace("4(b)", "4(c)")\n Revise().replace("Acme Corp.", "Acme, Inc.", all=True) # whole document\n\n`within` must occur exactly once in its paragraph (`at`) or document unless\n`occurrence` picks one. Each `select` must occur exactly once in the scope\nunless it passes `occurrence` or `all=True`. All selectors resolve against the\nscope as it stands when the operation starts, so later replacements never see\nearlier ones; overlapping selections are rejected.\n\n`AcceptRevision(target)` and `RejectRevision(target)` build `RevisionSettle`\noperations; `target` is a revision id from `await docx_read(path, kind="revisions")`\nor `"all"`. Put settle operations first: `"all"` also settles tracked changes\nmade earlier in the same plan.\n\nEvery operation dataclass has `to_dict()`, omitting optional fields whose\nvalue is `None`; `Plan.to_dict()` returns `{"author": ..., "change_mode": ..., "ops": [...]}`.\n\n## Host functions\n\n- `await docx_create(path: str, html: str)`\n- `await docx_read(path: str, view: str = "markup", kind: str = "document") -> ReadResult | dict` — complete HTML\n markup and a current equations-address listing (at, equation, display, editable) by default (`kind="document"`); `kind="comments"`, `"styles"`, `"revisions"`, or `"assets"` returns the\n core listing object (and a second positional of `comments`/`styles`/`revisions` is treated as `kind`).\n- `await docx_find(path: str, query: str, ignore_case: bool = False) -> list` — core find match\n records verbatim (`{index, id?, text, style?, number?}` per matching paragraph; `id` is\n the canonical uppercase paragraph address)\n- `await docx_help(topic: str | None = None) -> str`\n- `await docx_review(path: str, plan: Plan) -> ReviewResult | None` — validates the complete plan and returns `commit_key` plus bounded per-operation `edits` with affected markup context; returns `None` when validation is blocked\n- `await docx_commit(commit_key: str)` — commits the immutable reviewed plan; the key owns the\n target file and plan\nReview does not write the target. Review and commit must happen in separate later\nPython calls; changing the plan requires a new review.\n\n## Discovering paragraphs\n\n`docx_find` plus `docx_read` are the supported discovery workflow:\nfind returns core match records to locate paragraphs, and reads return\nstructural markup. Operations stay addressed\nby the markup-view paragraph `id`:\n\n matches = await docx_find("contract.docx", "Payment is due within")\n for m in matches:\n print(m["id"], m["text"])\n markup = (await docx_read("contract.docx")).markup\n\nReading `view="final"` or `view="original"` is allowed for analysis, but\nreview plans remain source-bound to the exact bytes and paragraph ids of the\ncurrent `markup` view.\n\n## Computing operations with regex\n\n`re` is pre-imported and `ReadResult.markup` is an ordinary string, so you can\ninspect it and build operations in Python loops. Return the full ReadResult or\nprint its full markup and semantic metadata when reading; prefer complete document\ncontext. Named DOCX style IDs appear in data-docx-style and map one-to-one to\nReadResult.styles (resolved CSS keyed by style ID). Presentation rules appear\nin data-docx-css and map to ReadResult.css. Both catalogs are omitted from the\ndefault display; inspect result.styles or result.css to read their definitions;\nwhen authoring a fragment, use equivalent inline formatting rather than copying\nan unresolved class. Image aliases map to canonical assets in ReadResult.assets.\nReadResult.selection_space describes comment selectors; ReadResult.selections\nprovides exact text for paragraphs where generated content or revisions make\nHTML text ambiguous:\n\n\n original = (await docx_read("contract.docx")).markup\n ops = []\n for m in re.finditer(r\'<p id="([0-9A-F]{8})"[^>]*>([^<]*?)</p>\', original):\n if "Payment is due within" in m.group(2):\n ops.append(ReplaceText(at=m.group(1), select=m.group(2),\n with_=re.sub(r\'within \\d+ days\', \'within 45 days\', m.group(2))))\n plan = Plan(author="Pi", operations=ops)\n review = await docx_review("contract.docx", plan)\n if review is not None:\n for edit in review.edits:\n print(edit["index"], edit["context"])\n # ...later python call...\n await docx_commit(review.commit_key)\n\n`select` must match the paragraph\'s current visible text exactly; on a document\nwith pending tracked changes, grep the `view="final"` projection instead.\n\n## Example\n\n original = (await docx_read("contract.docx")).markup\n plan = Plan(author="Pi", change_mode="track", operations=[\n ReplaceText(at="2673269E", select="thirty days", with_="sixty days"),\n FormatText(at="2673269E", select="sixty days", bold=True),\n ])\n review = await docx_review("contract.docx", plan)\n # Inspect review.edits before committing in a later Python call.\n if review is not None:\n print(review.commit_key)\n\n\nUse await quote_find(source, query) to obtain the paragraph id and exact source text.\nCreate Quote(source, at, select, occurrence=1, style="double"), where select is one contiguous,\nverbatim source span. Adapt it with .omit(exact_source_text) and\n.bracket(exact_source_text, replacement). Adaptation selectors always refer to\nthe original span; overlaps fail. Finish with await quote_validate(q). Only the host\nconstructs QuoteResult.text, including curly double quotation marks by default\n(style="single" or style="none" are also available). Its provenance binds the source SHA-256, paragraph\nSHA-256, paragraph id, offsets, original span, and every adaptation.\n\nRaw quotation marks in ordinary authored text are lint errors. For a\nnon-quotational mention such as the word quotation, use\nawait term_render(Term("quotation")); its quote marks are host-created and its\nprovenance is classified as kind="term" rather than a source quotation.\nFor authored document content, prefer Inline([plain_text, Term(...), Quote(...)]).\nawait inline_validate() automatically lints every plain-text part before the host\nrenders structured nodes. The default policy is error; comment must be chosen\nexplicitly and returns warning spans that require protected comment bubbles.\nIn the plan REPL\'s flagged quotation mode, pass Quote, Term, or Inline directly\nto an operation with_ field. docx_review resolves the node and binds its source;\ncopying QuoteResult.text into a plain string loses that provenance boundary.\n\nExample:\nq = Quote("source.docx", at="89ABCDEF", select="The tenant must promptly pay the charge.")\nq.omit(" promptly")\nq.bracket("tenant", "Tenant")\nvalidated = await quote_validate(q)\nprint(validated.text)\nprint(validated.provenance)\n'
3
+ REFERENCE = '\n# docxdriver Python REPL API — Version 1 (Python-authored plan)\n\n# BEGIN GENERATED OPERATION DOCS\n## Operations\n\nSelected from the core schema (`$defs.editOp.oneOf`) through the\ncapability manifest; field shapes, types, and enums are exactly the\nchecked-in schema’s. Every op dataclass has `to_dict()`, omitting\noptional fields whose value is `None`; constructors use `with_`/`as_`\nfor Python keyword collisions and `to_dict()` maps back to the schema\nfield names. Operations are addressed by the paragraph `id` attribute\nfrom `docx_read` (uppercase eight-digit hex) or a `$name` alias.\n\n### ClearFooter\n\n`ClearFooter(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit footer reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### ClearHeader\n\n`ClearHeader(kind: str | None = None, section: int | None = None)`\n\nRemove the explicit header reference for a section/kind (defaults: section 1, kind default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### CommentAdd\n\n`CommentAdd(at: str, text: str, occurrence: int | None = None, select: str | None = None)`\n\nAnchor a comment with `text` to the paragraph at `at`; omit `select` to comment the whole paragraph, or pass `select`/`occurrence` (1-based) to pin one exact span.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `text` | `str` | yes |\n| `occurrence` | `int | None` | no |\n| `select` | `str | None` | no |\n\n### CommentDelete\n\n`CommentDelete(comment_id: str)`\n\nDelete a comment by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n\n### CommentReply\n\n`CommentReply(comment_id: str, text: str)`\n\nReply to an existing comment; `comment_id` is the decimal id from a prior `comment_add` or a comments-kind read.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `text` | `str` | yes |\n\n### CommentSetStatus\n\n`CommentSetStatus(comment_id: str, status: str)`\n\nSet a comment\'s status to `open` or `resolved` by decimal `comment_id`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `comment_id` | `str` | yes |\n| `status` | `str` | yes |\n\n### DeleteEquation\n\n`DeleteEquation(at: str, equation: int | None = None)`\n\nDelete a current equation while preserving the paragraph. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Tracked mode keeps the equation recoverable until the deletion is accepted.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `equation` | `int | None` | no |\n\n### DeleteParagraphs\n\n`DeleteParagraphs(at: list)`\n\nDelete the paragraphs listed in `at` (tracked deletion by default).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n\n### EditTable\n\n`EditTable(action: dict, at: str)`\n\nDirect structural table edits using a source-bound table handle. action.kind is insert_rows/delete_rows/insert_columns/delete_columns/merge_cells/split_cell/delete; coordinates and counts are one-based. Insert/delete grid operations and merge require an unmerged rectangular grid. Split keeps content in the first resulting cell; merge concatenates content in row-major order. Rejects tracked mode and tables containing unresolved revisions, bookmarks, comments, fields or content controls. Delete the table instead of its final row or column.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `dict` | yes |\n| `at` | `str` | yes |\n\n### FormatParagraph\n\n`FormatParagraph(at: str, alignment: str | None = None, clear: list | None = None, first_line_indent: float | None = None, indent_left: float | None = None, indent_right: float | None = None, line_spacing: dict | None = None, space_after: float | None = None, space_before: float | None = None, style: str | None = None)`\n\nApply paragraph formatting to the paragraph at `at`: `style`, `alignment` (left/center/right/justify), signed `indent_left`/`indent_right`, signed `first_line_indent` (negative for hanging, zero for an explicit zero offset), `space_before`/`space_after`, `line_spacing` (dict with `mode` multiple/exact/at_least and `value`), or `clear`. Omitted properties are untouched; clear removes direct settings and inherits styles/defaults, winning over set. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `alignment` | `str | None` | no |\n| `clear` | `list | None` | no |\n| `first_line_indent` | `float | None` | no |\n| `indent_left` | `float | None` | no |\n| `indent_right` | `float | None` | no |\n| `line_spacing` | `dict | None` | no |\n| `space_after` | `float | None` | no |\n| `space_before` | `float | None` | no |\n| `style` | `str | None` | no |\n\n### FormatText\n\n`FormatText(at: str, select: str, bold: bool | None = None, clear: list | None = None, color: str | None = None, font_size: float | None = None, highlight: str | None = None, italic: bool | None = None, occurrence: int | None = None, strike: bool | None = None, subscript: bool | None = None, superscript: bool | None = None, underline: bool | None = None)`\n\nApply character formatting to an exact occurrence of `select` in the paragraph at `at`: boolean attributes (`bold`, `italic`, `underline`, `strike`, `superscript`, `subscript`), `color` (#RRGGBB), `highlight` (Word palette including none), `font_size` (points), `occurrence`, or `clear`. Omitted properties preserve source OOXML; false explicitly overrides inherited formatting (baseline for superscript/subscript). Clear removes the direct property and inherits styles/defaults, winning over set; superscript/subscript share one property. Formatting changes are always tracked.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `bold` | `bool | None` | no |\n| `clear` | `list | None` | no |\n| `color` | `str | None` | no |\n| `font_size` | `float | None` | no |\n| `highlight` | `str | None` | no |\n| `italic` | `bool | None` | no |\n| `occurrence` | `int | None` | no |\n| `strike` | `bool | None` | no |\n| `subscript` | `bool | None` | no |\n| `superscript` | `bool | None` | no |\n| `underline` | `bool | None` | no |\n\n### InsertParagraph\n\n`InsertParagraph(at: str, position: str, as_: str | None = None, style: str | None = None, with_: str | Inline | Quote | Term | None = None)`\n\nInsert a new paragraph before or after an anchor: paragraph id/alias (`at`) or body-level table number (`table:N` from markup); `with` is its text, `style` its style, and `as` names the new paragraph id (a `$name` alias).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `position` | `str` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n| `style` | `str | None` | no |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term | None` | no |\n\n### InsertTable\n\n`InsertTable(at: str, columns: int, position: str, rows: int, as_: str | None = None)`\n\nInsert a rectangular table before/after paragraph at, with rows and columns in 1..100. Direct mode only. Optional as creates a plan-local table alias for later formatting and structural edits.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `columns` | `int` | yes |\n| `position` | `str` | yes |\n| `rows` | `int` | yes |\n| `as_` (schema key `as`) | `str | None` | no |\n\n### ReplaceEquation\n\n`ReplaceEquation(at: str, mathml: str, display: bool | None = None, equation: int | None = None)`\n\nReplace a current equation using presentation MathML. Read await docx_read(path).equations for at and equation (1-based). Omit equation only when the paragraph has one. Display mode is preserved by default. Settle existing revisions before editing their equations.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `mathml` | `str` | yes |\n| `display` | `bool | None` | no |\n| `equation` | `int | None` | no |\n\n### ReplaceParagraph\n\n`ReplaceParagraph(at: str, with_: str | Inline | Quote | Term)`\n\nReplace the entire paragraph at `at` with `with`.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n\n### ReplaceText\n\n`ReplaceText(at: str, select: str, with_: str | Inline | Quote | Term, occurrence: int | None = None)`\n\nReplace an exact occurrence of `select` in the paragraph at `at` with `with`; `occurrence` picks the nth match (1-based).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `str` | yes |\n| `select` | `str` | yes |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `occurrence` | `int | None` | no |\n\n### ReviseText\n\n`ReviseText(edits: list, at: str | None = None, occurrence: int | None = None, within: str | None = None)`\n\nSeveral exact replacements inside one bounded scope; prefer the chainable `Revise(within, at=None, occurrence=None).replace(select, with_, occurrence=None, all=False)` builder. Scope: `within` (a phrase unique in paragraph `at`, or in the whole document when `at` is omitted; `occurrence` picks a repeated one), else the paragraph `at`, else the whole document. Each edit must match exactly once in the scope unless it sets `occurrence` or `all`. All edits resolve against the scope as it stands when the operation starts, must not overlap, and become separate minimal tracked changes.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `edits` | `list` | yes |\n| `at` | `str | None` | no |\n| `occurrence` | `int | None` | no |\n| `within` | `str | None` | no |\n\n### RevisionSettle\n\n`RevisionSettle(action: str, target: str)`\n\nAccept or reject tracked changes: `target` is a decimal revision id from `await docx_read(path, kind="revisions")` or `all`; `action` is `accept` or `reject`. Prefer the `AcceptRevision(target)` / `RejectRevision(target)` shorthands. Settling `all` also settles tracked changes made by earlier operations in the same plan, and later selectors address the settled text.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `action` | `str` | yes |\n| `target` | `str` | yes |\n\n### SetEvenAndOddHeaders\n\n`SetEvenAndOddHeaders(even_and_odd: bool)`\n\nEnable or disable distinct even-page headers/footers (`even_and_odd`); when true, `kind="even"` chrome applies to even pages.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `even_and_odd` | `bool` | yes |\n\n### SetFirstPageMode\n\n`SetFirstPageMode(enabled: bool, section: int | None = None)`\n\nEnable/disable first-page stories in a section (default 1) without removing stored stories. Tracked mode records a section-property snapshot; settle pending section changes before editing again.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `enabled` | `bool` | yes |\n| `section` | `int | None` | no |\n\n### SetFooter\n\n`SetFooter(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section footer chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetHeader\n\n`SetHeader(with_: str | Inline | Quote | Term, kind: str | None = None, section: int | None = None)`\n\nSet section header chrome from inner HTML (`with`); optional `section` (default 1) and `kind` (default/even/first).\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `with_` (schema key `with`) | `str | Inline | Quote | Term` | yes |\n| `kind` | `str | None` | no |\n| `section` | `int | None` | no |\n\n### SetList\n\n`SetList(at: list, format: str | None = None, level: int | None = None, levels: list | None = None, num_id: int | None = None, remove: bool | None = None, start: int | None = None)`\n\nAssign explicitly selected paragraphs to a new list (format defaults to decimal), continue an existing num_id, or remove membership with remove=True. level is 0..8; start is positive and creates an isolated restarted instance. Optional levels defines 1..9 per-level format/label/start/restart_after, signed indent_left/indent_right/first_line_indent and marker_bold/marker_italic/marker_font/marker_color for a new list. Label placeholders reference this or ancestor levels (%1..%9). Existing definitions are never restyled. Membership changes are tracked; settle pending paragraph changes first. Definitions remain after rejection for safe reference restoration.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `at` | `list` | yes |\n| `format` | `str | None` | no |\n| `level` | `int | None` | no |\n| `levels` | `list | None` | no |\n| `num_id` | `int | None` | no |\n| `remove` | `bool | None` | no |\n| `start` | `int | None` | no |\n\n### SetPageMargins\n\n`SetPageMargins(bottom: float | None = None, footer: float | None = None, gutter: float | None = None, header: float | None = None, left: float | None = None, right: float | None = None, section: int | None = None, top: float | None = None)`\n\nSet selected nonnegative page margins in points for a section (default 1): top/right/bottom/left/header/footer/gutter. Omitted values are preserved. Like existing header/footer setters, margin changes are direct even in tracked plans.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `bottom` | `float | None` | no |\n| `footer` | `float | None` | no |\n| `gutter` | `float | None` | no |\n| `header` | `float | None` | no |\n| `left` | `float | None` | no |\n| `right` | `float | None` | no |\n| `section` | `int | None` | no |\n| `top` | `float | None` | no |\n\n### SetSectionLayout\n\n`SetSectionLayout(break_type: str | None = None, column_separator: bool | None = None, column_spacing: float | None = None, column_widths: list | None = None, columns: int | None = None, height: float | None = None, orientation: str | None = None, page_number_continue: bool | None = None, page_number_format: str | None = None, page_number_start: int | None = None, page_size: str | None = None, section: int | None = None, width: float | None = None)`\n\nSelectively set section layout (section defaults to 1): page_size letter/legal/a4/a3/a5 or paired width/height in points; orientation portrait/landscape normalizes dimensions. Columns 1..45 with column_spacing, column_widths and column_separator; break_type nextPage/continuous/oddPage/evenPage/nextColumn; page_number_format, page_number_start or page_number_continue=true. Omission preserves properties; tracking snapshots section properties while preserving story relationships. Pending property changes must be settled first.\n\n| Field | Type | Required |\n| --- | --- | --- |\n| `break_type` | `str | None` | no |\n| `column_separator` | `bool | None` | no |\n| `column_spacing` | `float | None` | no |\n| `column_widths` | `list | None` | no |\n| `columns` | `int | None` | no |\n| `height` | `float | None` | no |\n| `orientation` | `str | None` | no |\n| `page_number_continue` | `bool | None` | no |\n| `page_number_format` | `str | None` | no |\n| `page_number_start` | `int | None` | no |\n| `page_size` | `str | None` | no |\n| `section` | `int | None` | no |\n| `width` | `float | None` | no |\n\n# END GENERATED OPERATION DOCS\n\n## Scoped revisions\n\n`Revise(within=None, at=None, occurrence=None)` builds one `ReviseText`\noperation; chain `.replace(select, with_, occurrence=None, all=False)` for each\nchange. The scope bounds every `select`, so short selectors stay unambiguous\nin long documents and each change becomes a minimal tracked change:\n\n # unique phrase, no paragraph id needed:\n Revise("the tenant shall pay rent within thirty days").replace("thirty", "sixty").replace("rent", "the rent")\n Revise(at="2673269E").replace("Tenant", "Lessee", all=True) # whole paragraph\n Revise("Section 4(b)", at="2673269E", occurrence=2).replace("4(b)", "4(c)")\n Revise().replace("Acme Corp.", "Acme, Inc.", all=True) # whole document\n\n`within` must occur exactly once in its paragraph (`at`) or document unless\n`occurrence` picks one. Each `select` must occur exactly once in the scope\nunless it passes `occurrence` or `all=True`. All selectors resolve against the\nscope as it stands when the operation starts, so later replacements never see\nearlier ones; overlapping selections are rejected.\n\n`AcceptRevision(target)` and `RejectRevision(target)` build `RevisionSettle`\noperations; `target` is a revision id from `await docx_read(path, kind="revisions")`\nor `"all"`. Put settle operations first: `"all"` also settles tracked changes\nmade earlier in the same plan.\n\nEvery operation dataclass has `to_dict()`, omitting optional fields whose\nvalue is `None`; `Plan.to_dict()` returns `{"author": ..., "change_mode": ..., "ops": [...]}`.\n\nTracked plain-text replacements automatically compute word-level redlines in\nReplaceText, ReviseText, and ReplaceParagraph. Unchanged words retain their\nformatting; adjacent changed words form one deletion followed by one insertion.\nRich replacements retain whole-selection tracking to apply their formatting.\n\n## Host functions\n\n- `await docx_create(path: str, html: str)`\n- `await docx_read(path: str, view: str = "markup", kind: str = "document") -> ReadResult | dict` — complete HTML\n markup and a current equations-address listing (at, equation, display, editable) by default (`kind="document"`); `kind="comments"`, `"styles"`, `"revisions"`, or `"assets"` returns the\n core listing object (and a second positional of `comments`/`styles`/`revisions` is treated as `kind`).\n- `await docx_find(path: str, query: str, ignore_case: bool = False) -> list` — core find match\n records verbatim (`{index, id?, text, style?, number?}` per matching paragraph; `id` is\n the canonical uppercase paragraph address)\n- `await docx_help(topic: str | None = None) -> str`\n- `await docx_review(path: str, plan: Plan) -> ReviewResult | None` — validates the complete plan and returns `commit_key` plus bounded per-operation `edits` with affected markup context; returns `None` when validation is blocked\n- `await docx_commit(commit_key: str)` — commits the immutable reviewed plan; the key owns the\n target file and plan\nReview does not write the target. Review and commit must happen in separate later\nPython calls; changing the plan requires a new review.\n\n## Discovering paragraphs\n\n`docx_find` plus `docx_read` are the supported discovery workflow:\nfind returns core match records to locate paragraphs, and reads return\nstructural markup. Operations stay addressed\nby the markup-view paragraph `id`:\n\n matches = await docx_find("contract.docx", "Payment is due within")\n for m in matches:\n print(m["id"], m["text"])\n markup = (await docx_read("contract.docx")).markup\n\nReading `view="final"` or `view="original"` is allowed for analysis, but\nreview plans remain source-bound to the exact bytes and paragraph ids of the\ncurrent `markup` view.\n\n## Computing operations with regex\n\n`re` is pre-imported and `ReadResult.markup` is an ordinary string, so you can\ninspect it and build operations in Python loops. Return the full ReadResult or\nprint its full markup and semantic metadata when reading; prefer complete document\ncontext. Named DOCX style IDs appear in data-docx-style and map one-to-one to\nReadResult.styles (resolved CSS keyed by style ID). Presentation rules appear\nin data-docx-css and map to ReadResult.css. Both catalogs are omitted from the\ndefault display; inspect result.styles or result.css to read their definitions;\nwhen authoring a fragment, use equivalent inline formatting rather than copying\nan unresolved class. Image aliases map to canonical assets in ReadResult.assets.\nReadResult.selection_space describes comment selectors; ReadResult.selections\nprovides exact text for paragraphs where generated content or revisions make\nHTML text ambiguous:\n\n\n original = (await docx_read("contract.docx")).markup\n ops = []\n for m in re.finditer(r\'<p id="([0-9A-F]{8})"[^>]*>([^<]*?)</p>\', original):\n if "Payment is due within" in m.group(2):\n ops.append(ReplaceText(at=m.group(1), select=m.group(2),\n with_=re.sub(r\'within \\d+ days\', \'within 45 days\', m.group(2))))\n plan = Plan(author="Pi", operations=ops)\n review = await docx_review("contract.docx", plan)\n if review is not None:\n for edit in review.edits:\n print(edit["index"], edit["context"])\n # ...later python call...\n await docx_commit(review.commit_key)\n\n`select` must match the paragraph\'s current visible text exactly; on a document\nwith pending tracked changes, grep the `view="final"` projection instead.\n\n## Example\n\n original = (await docx_read("contract.docx")).markup\n plan = Plan(author="Pi", change_mode="track", operations=[\n ReplaceText(at="2673269E", select="thirty days", with_="sixty days"),\n FormatText(at="2673269E", select="sixty days", bold=True),\n ])\n review = await docx_review("contract.docx", plan)\n # Inspect review.edits before committing in a later Python call.\n if review is not None:\n print(review.commit_key)\n\n\nUse await quote_find(source, query) to obtain the paragraph id and exact source text.\nCreate Quote(source, at, select, occurrence=1, style="double"), where select is one contiguous,\nverbatim source span. Adapt it with .omit(exact_source_text) and\n.bracket(exact_source_text, replacement). Adaptation selectors always refer to\nthe original span; overlaps fail. Finish with await quote_validate(q). Only the host\nconstructs QuoteResult.text, including curly double quotation marks by default\n(style="single" or style="none" are also available). Its provenance binds the source SHA-256, paragraph\nSHA-256, paragraph id, offsets, original span, and every adaptation.\n\nRaw quotation marks in ordinary authored text are lint errors. For a\nnon-quotational mention such as the word quotation, use\nawait term_render(Term("quotation")); its quote marks are host-created and its\nprovenance is classified as kind="term" rather than a source quotation.\nFor authored document content, prefer Inline([plain_text, Term(...), Quote(...)]).\nawait inline_validate() automatically lints every plain-text part before the host\nrenders structured nodes. The default policy is error; comment must be chosen\nexplicitly and returns warning spans that require protected comment bubbles.\nIn the plan REPL\'s flagged quotation mode, pass Quote, Term, or Inline directly\nto an operation with_ field. docx_review resolves the node and binds its source;\ncopying QuoteResult.text into a plain string loses that provenance boundary.\n\nExample:\nq = Quote("source.docx", at="89ABCDEF", select="The tenant must promptly pay the charge.")\nq.omit(" promptly")\nq.bracket("tenant", "Tenant")\nvalidated = await quote_validate(q)\nprint(validated.text)\nprint(validated.provenance)\n'
File without changes
File without changes