docxdriver 0.3.0__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 (75) hide show
  1. {docxdriver-0.3.0 → docxdriver-0.3.2}/Cargo.lock +5 -5
  2. {docxdriver-0.3.0 → docxdriver-0.3.2}/Cargo.toml +1 -1
  3. {docxdriver-0.3.0 → 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.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/api.rs +348 -7
  6. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/bin/generate_typed_schema.rs +23 -2
  7. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/comments.rs +68 -20
  8. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/headers.rs +2 -20
  9. docxdriver-0.3.2/crates/docxdriver-core/src/commands/layout.rs +395 -0
  10. docxdriver-0.3.2/crates/docxdriver-core/src/commands/lists.rs +141 -0
  11. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/mod.rs +88 -0
  12. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/readonly.rs +9 -2
  13. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/revisions.rs +35 -2
  14. docxdriver-0.3.2/crates/docxdriver-core/src/commands/table_structure.rs +100 -0
  15. docxdriver-0.3.2/crates/docxdriver-core/src/commands/tables.rs +128 -0
  16. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/typed.rs +360 -178
  17. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/document.rs +46 -21
  18. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/build.rs +48 -23
  19. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/chrome.rs +3 -1
  20. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/ctx.rs +44 -5
  21. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/input.rs +66 -9
  22. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/mod.rs +2 -8
  23. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/parse.rs +11 -0
  24. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/render.rs +14 -4
  25. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/styles.rs +92 -30
  26. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/surface.rs +24 -1
  27. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/segments.rs +1 -23
  28. docxdriver-0.3.2/crates/docxdriver-core/tests/formatting_fidelity.rs +840 -0
  29. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/html_e2e.rs +13 -22
  30. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/typed_api.rs +244 -16
  31. docxdriver-0.3.2/crates/docxdriver-python/python/docxdriver/repl/prelude.py +3 -0
  32. docxdriver-0.3.2/python/docxdriver/repl/prelude.py +3 -0
  33. docxdriver-0.3.0/crates/docxdriver-core/src/commands/layout.rs +0 -243
  34. docxdriver-0.3.0/crates/docxdriver-python/python/docxdriver/repl/prelude.py +0 -3
  35. docxdriver-0.3.0/python/docxdriver/repl/prelude.py +0 -3
  36. {docxdriver-0.3.0 → docxdriver-0.3.2}/README.md +0 -0
  37. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/Cargo.toml +0 -0
  38. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/README.md +0 -0
  39. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/assets/blank.docx +0 -0
  40. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/examples/plan_scaling.rs +0 -0
  41. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/equations.rs +0 -0
  42. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/commands/reading.rs +0 -0
  43. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/README.md +0 -0
  44. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/assets.rs +0 -0
  45. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/compact.rs +0 -0
  46. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/mathml_omml.rs +0 -0
  47. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/omml_latex.rs +0 -0
  48. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/html/omml_mathml.rs +0 -0
  49. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/lib.rs +0 -0
  50. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/media.rs +0 -0
  51. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/outcome.rs +0 -0
  52. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/src/package.rs +0 -0
  53. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/README.md +0 -0
  54. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/projection-parity/features.json +0 -0
  55. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/projection-parity/parity.docx +0 -0
  56. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/projection-parity/prior-projection.json +0 -0
  57. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/fixtures/ts-reference.docx +0 -0
  58. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/interop.rs +0 -0
  59. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/package_layout.rs +0 -0
  60. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/projection_parity.rs +0 -0
  61. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-core/tests/spike.rs +0 -0
  62. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/.gitignore +0 -0
  63. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/Cargo.toml +0 -0
  64. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/README.md +0 -0
  65. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/__init__.py +0 -0
  66. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/repl/__init__.py +0 -0
  67. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/repl/host.py +0 -0
  68. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/python/docxdriver/repl/quotes.py +0 -0
  69. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/src/lib.rs +0 -0
  70. {docxdriver-0.3.0 → docxdriver-0.3.2}/crates/docxdriver-python/tests/test_host.py +0 -0
  71. {docxdriver-0.3.0 → docxdriver-0.3.2}/pyproject.toml +0 -0
  72. {docxdriver-0.3.0 → docxdriver-0.3.2}/python/docxdriver/__init__.py +0 -0
  73. {docxdriver-0.3.0 → docxdriver-0.3.2}/python/docxdriver/repl/__init__.py +0 -0
  74. {docxdriver-0.3.0 → docxdriver-0.3.2}/python/docxdriver/repl/host.py +0 -0
  75. {docxdriver-0.3.0 → 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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0
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
+ }
@@ -10,6 +10,164 @@ use serde_json::{Map, Value};
10
10
  use sha2::{Digest, Sha256};
11
11
  use std::collections::BTreeMap;
12
12
 
13
+ /// The exact ECMA-376 ST_HighlightColor palette; None is an explicit off override.
14
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15
+ #[serde(rename_all = "camelCase")]
16
+ pub enum HighlightColor {
17
+ Black,
18
+ Blue,
19
+ Cyan,
20
+ Green,
21
+ Magenta,
22
+ Red,
23
+ Yellow,
24
+ White,
25
+ DarkBlue,
26
+ DarkCyan,
27
+ DarkGreen,
28
+ DarkMagenta,
29
+ DarkRed,
30
+ DarkYellow,
31
+ DarkGray,
32
+ LightGray,
33
+ None,
34
+ }
35
+ impl HighlightColor {
36
+ pub fn as_str(self) -> &'static str { match self {
37
+ Self::Black => "black",
38
+ Self::Blue => "blue",
39
+ Self::Cyan => "cyan",
40
+ Self::Green => "green",
41
+ Self::Magenta => "magenta",
42
+ Self::Red => "red",
43
+ Self::Yellow => "yellow",
44
+ Self::White => "white",
45
+ Self::DarkBlue => "darkBlue",
46
+ Self::DarkCyan => "darkCyan",
47
+ Self::DarkGreen => "darkGreen",
48
+ Self::DarkMagenta => "darkMagenta",
49
+ Self::DarkRed => "darkRed",
50
+ Self::DarkYellow => "darkYellow",
51
+ Self::DarkGray => "darkGray",
52
+ Self::LightGray => "lightGray",
53
+ Self::None => "none",
54
+ }}
55
+ pub fn css(self) -> &'static str { match self {
56
+ Self::Black => "#000000",
57
+ Self::Blue => "#0000FF",
58
+ Self::Cyan => "#00FFFF",
59
+ Self::Green => "#00FF00",
60
+ Self::Magenta => "#FF00FF",
61
+ Self::Red => "#FF0000",
62
+ Self::Yellow => "#FFFF00",
63
+ Self::White => "#FFFFFF",
64
+ Self::DarkBlue => "#000080",
65
+ Self::DarkCyan => "#008080",
66
+ Self::DarkGreen => "#008000",
67
+ Self::DarkMagenta => "#800080",
68
+ Self::DarkRed => "#800000",
69
+ Self::DarkYellow => "#808000",
70
+ Self::DarkGray => "#808080",
71
+ Self::LightGray => "#C0C0C0",
72
+ Self::None => "transparent",
73
+ }}
74
+ pub fn parse(value: &str) -> Result<Self, String> { match value {
75
+ "black" => Ok(Self::Black),
76
+ "blue" => Ok(Self::Blue),
77
+ "cyan" => Ok(Self::Cyan),
78
+ "green" => Ok(Self::Green),
79
+ "magenta" => Ok(Self::Magenta),
80
+ "red" => Ok(Self::Red),
81
+ "yellow" => Ok(Self::Yellow),
82
+ "white" => Ok(Self::White),
83
+ "darkBlue" => Ok(Self::DarkBlue),
84
+ "darkCyan" => Ok(Self::DarkCyan),
85
+ "darkGreen" => Ok(Self::DarkGreen),
86
+ "darkMagenta" => Ok(Self::DarkMagenta),
87
+ "darkRed" => Ok(Self::DarkRed),
88
+ "darkYellow" => Ok(Self::DarkYellow),
89
+ "darkGray" => Ok(Self::DarkGray),
90
+ "lightGray" => Ok(Self::LightGray),
91
+ "none" => Ok(Self::None),
92
+ _ => Err(format!("unknown highlight palette value {value:?}")),
93
+ }}
94
+ }
95
+
96
+ /// Table widths use auto, points, or percent (0 < value <= 100 for percent).
97
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
98
+ #[serde(tag = "unit", rename_all = "snake_case", deny_unknown_fields)]
99
+ pub enum TableWidth { Auto, Points { value: Points }, Percent { value: f64 } }
100
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
101
+ #[serde(deny_unknown_fields)]
102
+ pub struct TableBorder {
103
+ pub style: String,
104
+ #[serde(default)] pub color: Option<String>,
105
+ #[serde(default)] pub width: Option<Points>,
106
+ }
107
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
108
+ #[serde(deny_unknown_fields)]
109
+ pub struct TableBorders {
110
+ #[serde(default)] pub top: Option<TableBorder>,
111
+ #[serde(default)] pub left: Option<TableBorder>,
112
+ #[serde(default)] pub bottom: Option<TableBorder>,
113
+ #[serde(default)] pub right: Option<TableBorder>,
114
+ #[serde(default)] pub inside_horizontal: Option<TableBorder>,
115
+ #[serde(default)] pub inside_vertical: Option<TableBorder>,
116
+ }
117
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
118
+ #[serde(deny_unknown_fields)]
119
+ pub struct CellPadding {
120
+ #[serde(default)] pub top: Option<Points>,
121
+ #[serde(default)] pub right: Option<Points>,
122
+ #[serde(default)] pub bottom: Option<Points>,
123
+ #[serde(default)] pub left: Option<Points>,
124
+ }
125
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
126
+ #[serde(deny_unknown_fields)]
127
+ pub struct TableFormat {
128
+ #[serde(default)] pub width: Option<TableWidth>,
129
+ #[serde(default)] pub column_widths: Option<Vec<Points>>,
130
+ #[serde(default)] pub layout: Option<String>,
131
+ #[serde(default)] pub alignment: Option<String>,
132
+ #[serde(default)] pub borders: Option<TableBorders>,
133
+ #[serde(default)] pub cell_spacing: Option<Points>,
134
+ #[serde(default)] pub padding: Option<CellPadding>,
135
+ #[serde(default)] pub style: Option<String>,
136
+ #[serde(default)] pub clear: Vec<String>,
137
+ }
138
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
139
+ #[serde(deny_unknown_fields)]
140
+ pub struct CellFormat {
141
+ #[serde(default)] pub width: Option<TableWidth>,
142
+ #[serde(default)] pub borders: Option<TableBorders>,
143
+ #[serde(default)] pub fill: Option<String>,
144
+ #[serde(default)] pub padding: Option<CellPadding>,
145
+ #[serde(default)] pub vertical_alignment: Option<String>,
146
+ #[serde(default)] pub clear: Vec<String>,
147
+ }
148
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
149
+ #[serde(deny_unknown_fields)]
150
+ pub struct RowFormat {
151
+ #[serde(default)] pub height: Option<Points>,
152
+ #[serde(default)] pub height_rule: Option<String>,
153
+ #[serde(default)] pub repeating_header: Option<bool>,
154
+ #[serde(default)] pub allow_split: Option<bool>,
155
+ #[serde(default)] pub clear: Vec<String>,
156
+ }
157
+
158
+ /// Direct structural table edits. Row and column positions are one-based.
159
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
160
+ #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
161
+ pub enum TableEdit {
162
+ InsertRows { row: u32, position: InsertPosition, count: u32 },
163
+ DeleteRows { row: u32, count: u32 },
164
+ InsertColumns { column: u32, position: InsertPosition, count: u32 },
165
+ DeleteColumns { column: u32, count: u32 },
166
+ MergeCells { row: u32, column: u32, row_span: u32, column_span: u32 },
167
+ SplitCell { row: u32, column: u32 },
168
+ Delete,
169
+ }
170
+
13
171
  pub const EDIT_PROTOCOL_VERSION: &str = "1";
14
172
 
15
173
  #[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
@@ -98,6 +256,34 @@ impl<'de> Deserialize<'de> for Points {
98
256
  }
99
257
  }
100
258
 
259
+ /// Signed paragraph indentation, in twentieths of a point. Other dimensions use `Points`.
260
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
261
+ #[schemars(with = "f64")]
262
+ pub struct SignedPoints(pub i64);
263
+ impl SignedPoints {
264
+ pub fn from_points(value: f64) -> Result<Self, String> {
265
+ if !value.is_finite() || value.abs() > 100000.0 {
266
+ return Err("indentation must be finite and between -100000 and 100000 points".into());
267
+ }
268
+ let n = (value * 20.0).round();
269
+ if (value * 20.0 - n).abs() > 1e-8 {
270
+ return Err("point values must be multiples of 0.05".into());
271
+ }
272
+ Ok(Self(n as i64))
273
+ }
274
+ pub fn as_points(self) -> f64 { self.0 as f64 / 20.0 }
275
+ }
276
+ impl Serialize for SignedPoints {
277
+ fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
278
+ s.serialize_f64(self.as_points())
279
+ }
280
+ }
281
+ impl<'de> Deserialize<'de> for SignedPoints {
282
+ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
283
+ Self::from_points(f64::deserialize(d)?).map_err(serde::de::Error::custom)
284
+ }
285
+ }
286
+
101
287
  #[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
102
288
  #[schemars(with = "String")]
103
289
  pub enum ParagraphAddress {
@@ -317,6 +503,24 @@ pub enum LineSpacing {
317
503
  AtLeast(Points),
318
504
  }
319
505
 
506
+ /// One bounded level in a new multilevel list definition. Array position is level (0..8).
507
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
508
+ #[serde(deny_unknown_fields)]
509
+ pub struct ListLevel {
510
+ pub format: String,
511
+ pub label: String,
512
+ #[serde(default)] pub start: Option<u32>,
513
+ /// Restart after this zero-based ancestor level; omitted uses Word's default.
514
+ #[serde(default)] pub restart_after: Option<u8>,
515
+ #[serde(default)] pub indent_left: Option<SignedPoints>,
516
+ #[serde(default)] pub indent_right: Option<SignedPoints>,
517
+ #[serde(default)] pub first_line_indent: Option<SignedPoints>,
518
+ #[serde(default)] pub marker_bold: Option<bool>,
519
+ #[serde(default)] pub marker_italic: Option<bool>,
520
+ #[serde(default)] pub marker_font: Option<String>,
521
+ #[serde(default)] pub marker_color: Option<String>,
522
+ }
523
+
320
524
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
321
525
  #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
322
526
  pub enum EditOp {
@@ -359,6 +563,11 @@ pub enum EditOp {
359
563
  at: ParagraphAddress,
360
564
  with: String,
361
565
  },
566
+ EditTable { at: String, action: TableEdit },
567
+ InsertTable { at: ParagraphAddress, position: InsertPosition, rows: u32, columns: u32, #[serde(rename = "as", default)] alias: Option<String> },
568
+ FormatTable { at: String, format: TableFormat },
569
+ FormatRow { at: String, format: RowFormat },
570
+ FormatCell { at: String, format: CellFormat },
362
571
  FormatText {
363
572
  at: ParagraphAddress,
364
573
  select: String,
@@ -379,6 +588,8 @@ pub enum EditOp {
379
588
  #[serde(default)]
380
589
  color: Option<String>,
381
590
  #[serde(default)]
591
+ highlight: Option<HighlightColor>,
592
+ #[serde(default)]
382
593
  font_size: Option<Points>,
383
594
  #[serde(default)]
384
595
  clear: Vec<String>,
@@ -390,9 +601,12 @@ pub enum EditOp {
390
601
  #[serde(default)]
391
602
  alignment: Option<String>,
392
603
  #[serde(default)]
393
- indent_left: Option<Points>,
604
+ indent_left: Option<SignedPoints>,
394
605
  #[serde(default)]
395
- indent_right: Option<Points>,
606
+ indent_right: Option<SignedPoints>,
607
+ /// Signed first-line offset: negative means hanging; zero explicitly disables it.
608
+ #[serde(default, skip_serializing_if = "Option::is_none")]
609
+ first_line_indent: Option<SignedPoints>,
396
610
  #[serde(default)]
397
611
  space_before: Option<Points>,
398
612
  #[serde(default)]
@@ -402,6 +616,23 @@ pub enum EditOp {
402
616
  #[serde(default)]
403
617
  clear: Vec<String>,
404
618
  },
619
+ /// Assign selected paragraphs to a new or existing list. `remove` explicitly
620
+ /// suppresses inherited numbering; new definitions remain after rejection.
621
+ SetList {
622
+ #[serde(default)]
623
+ levels: Option<Vec<ListLevel>>,
624
+ at: Vec<ParagraphAddress>,
625
+ #[serde(default)]
626
+ num_id: Option<u32>,
627
+ #[serde(default)]
628
+ format: Option<String>,
629
+ #[serde(default)]
630
+ level: u8,
631
+ #[serde(default)]
632
+ start: Option<u32>,
633
+ #[serde(default)]
634
+ remove: bool,
635
+ },
405
636
  InsertParagraph {
406
637
  at: InsertAnchor,
407
638
  position: InsertPosition,
@@ -433,6 +664,40 @@ pub enum EditOp {
433
664
  #[serde(default)]
434
665
  gutter: Option<Points>,
435
666
  },
667
+ /// Selective section-property edits. Dimensions are points; omitted fields preserve source.
668
+ SetSectionLayout {
669
+ #[serde(default)]
670
+ section: Option<u32>,
671
+ #[serde(default)]
672
+ page_size: Option<String>,
673
+ #[serde(default)]
674
+ width: Option<Points>,
675
+ #[serde(default)]
676
+ height: Option<Points>,
677
+ #[serde(default)]
678
+ orientation: Option<String>,
679
+ #[serde(default)]
680
+ columns: Option<u32>,
681
+ #[serde(default)]
682
+ column_spacing: Option<Points>,
683
+ #[serde(default)]
684
+ column_widths: Option<Vec<Points>>,
685
+ #[serde(default)]
686
+ column_separator: Option<bool>,
687
+ #[serde(default)]
688
+ break_type: Option<String>,
689
+ #[serde(default)]
690
+ page_number_format: Option<String>,
691
+ #[serde(default)]
692
+ page_number_start: Option<u32>,
693
+ #[serde(default)]
694
+ page_number_continue: Option<bool>,
695
+ },
696
+ SetFirstPageMode {
697
+ #[serde(default)]
698
+ section: Option<u32>,
699
+ enabled: bool,
700
+ },
436
701
  SetEvenAndOddHeaders {
437
702
  even_and_odd: bool,
438
703
  },
@@ -1093,6 +1358,14 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1093
1358
  }
1094
1359
  }
1095
1360
  }
1361
+ EditOp::EditTable { at, action } => crate::commands::table_structure::validate(at, action)?,
1362
+ EditOp::InsertTable { rows, columns, alias, .. } => {
1363
+ if *rows == 0 || *columns == 0 || *rows > 100 || *columns > 100 { return Err("insert_table dimensions must be 1..100".into()); }
1364
+ if alias.as_ref().is_some_and(|a| !valid_alias(a)) { return Err("invalid alias".into()); }
1365
+ }
1366
+ EditOp::FormatTable { at, format } => crate::commands::tables::validate_table(at, format)?,
1367
+ EditOp::FormatRow { at, format } => crate::commands::tables::validate_row(at, format)?,
1368
+ EditOp::FormatCell { at, format } => crate::commands::tables::validate_cell(at, format)?,
1096
1369
  EditOp::ReplaceParagraph { .. } => {}
1097
1370
  EditOp::FormatText {
1098
1371
  select,
@@ -1104,6 +1377,7 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1104
1377
  superscript,
1105
1378
  subscript,
1106
1379
  color,
1380
+ highlight,
1107
1381
  font_size,
1108
1382
  clear,
1109
1383
  ..
@@ -1133,6 +1407,7 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1133
1407
  && strike.is_none()
1134
1408
  && superscript.is_none()
1135
1409
  && subscript.is_none()
1410
+ && highlight.is_none()
1136
1411
  && color.is_none()
1137
1412
  && font_size.is_none()
1138
1413
  && clear.is_empty()
@@ -1147,6 +1422,7 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1147
1422
  "strike",
1148
1423
  "superscript",
1149
1424
  "subscript",
1425
+ "highlight",
1150
1426
  "color",
1151
1427
  "font_size",
1152
1428
  ]
@@ -1160,12 +1436,16 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1160
1436
  alignment,
1161
1437
  indent_left,
1162
1438
  indent_right,
1439
+ first_line_indent,
1163
1440
  space_before,
1164
1441
  space_after,
1165
1442
  line_spacing,
1166
1443
  clear,
1167
1444
  ..
1168
1445
  } => {
1446
+ if [*indent_left, *indent_right, *first_line_indent].into_iter().flatten().any(|v| v.0.unsigned_abs() > 2_000_000) {
1447
+ return Err("paragraph indentation must be between -100000 and 100000 points".into());
1448
+ }
1169
1449
  if let Some(s) = style {
1170
1450
  if s.is_empty() {
1171
1451
  return Err("style must be non-empty".into());
@@ -1183,6 +1463,7 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1183
1463
  }
1184
1464
  if indent_left.is_none()
1185
1465
  && indent_right.is_none()
1466
+ && first_line_indent.is_none()
1186
1467
  && space_before.is_none()
1187
1468
  && space_after.is_none()
1188
1469
  && line_spacing.is_none()
@@ -1193,6 +1474,36 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1193
1474
  return Err("format_paragraph requires a property".into());
1194
1475
  }
1195
1476
  }
1477
+ EditOp::SetList { at, num_id, format, level, start, remove, levels } => {
1478
+ if let Some(defs) = levels {
1479
+ if defs.is_empty() || defs.len() > 9 || num_id.is_some() || *remove || format.is_some() || *level as usize >= defs.len() {
1480
+ return Err("levels requires a new list with 1..9 definitions covering the selected level".into());
1481
+ }
1482
+ for (i, def) in defs.iter().enumerate() {
1483
+ if !["bullet","decimal","lowerLetter","upperLetter","lowerRoman","upperRoman"].contains(&def.format.as_str()) || def.start == Some(0) || def.label.is_empty() || def.label.len() > 255 || def.restart_after.is_some_and(|v| v as usize >= i) {
1484
+ return Err("invalid list level format, label, start or restart ancestor".into());
1485
+ }
1486
+ if [def.indent_left,def.indent_right,def.first_line_indent].into_iter().flatten().any(|v| v.0.unsigned_abs() > 2_000_000) { return Err("list indentation is out of range".into()); }
1487
+ let bytes = def.label.as_bytes();
1488
+ for j in 0..bytes.len() {
1489
+ if bytes[j] == b'%' && (j + 1 == bytes.len() || !(b'1'..=b'1' + i as u8).contains(&bytes[j+1])) { return Err("list labels may only reference this level or ancestor %1..%9 counters".into()); }
1490
+ }
1491
+ if def.marker_font.as_ref().is_some_and(|v| v.is_empty()) || def.marker_color.as_ref().is_some_and(|v| v.len() != 6 || !v.bytes().all(|c| c.is_ascii_hexdigit())) { return Err("invalid list marker font or six-digit RGB color".into()); }
1492
+ }
1493
+ }
1494
+ if at.is_empty() || *level > 8 || *start == Some(0) || *num_id == Some(0) {
1495
+ return Err("set_list requires paragraphs, level 0..8 and positive IDs/starts".into());
1496
+ }
1497
+ if *remove && (num_id.is_some() || format.is_some() || start.is_some() || *level != 0) {
1498
+ return Err("set_list remove cannot be combined with list settings".into());
1499
+ }
1500
+ if let Some(fmt) = format {
1501
+ if !["bullet", "decimal", "lowerLetter", "upperLetter", "lowerRoman", "upperRoman"].contains(&fmt.as_str()) {
1502
+ return Err("unsupported set_list format".into());
1503
+ }
1504
+ if num_id.is_some() { return Err("format cannot restyle an existing list; create a new list".into()); }
1505
+ }
1506
+ }
1196
1507
  EditOp::InsertParagraph { alias, .. } => {
1197
1508
  if let Some(a) = alias {
1198
1509
  if !valid_alias(a) {
@@ -1205,6 +1516,22 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1205
1516
  return Err("at must not be empty".into());
1206
1517
  }
1207
1518
  }
1519
+ EditOp::SetSectionLayout { section, page_size, width, height, orientation, columns, column_spacing, column_widths, column_separator, break_type, page_number_format, page_number_start, page_number_continue } => {
1520
+ if section == &Some(0) { return Err("section must be positive".into()); }
1521
+ if let Some(size) = page_size { if !matches!(size.as_str(), "letter" | "legal" | "a4" | "a3" | "a5") { return Err("page_size must be letter/legal/a4/a3/a5".into()); } }
1522
+ if page_size.is_some() && (width.is_some() || height.is_some()) { return Err("page_size conflicts with width/height".into()); }
1523
+ if width.is_some() != height.is_some() { return Err("width and height must be supplied together".into()); }
1524
+ if [width.as_ref(), height.as_ref()].into_iter().flatten().any(|value| value.0 <= 0 || value.0 > 31680) { return Err("page dimensions must be >0 and <=1584 points".into()); }
1525
+ if orientation.as_ref().is_some_and(|v| !matches!(v.as_str(), "portrait" | "landscape")) { return Err("orientation must be portrait/landscape".into()); }
1526
+ if columns.is_some_and(|v| v == 0 || v > 45) { return Err("columns must be between 1 and 45".into()); }
1527
+ if column_widths.as_ref().is_some_and(|v| v.is_empty() || v.len() > 45 || v.iter().any(|p| p.0 <= 0)) { return Err("column_widths requires 1..45 positive widths".into()); }
1528
+ if let (Some(count), Some(widths)) = (columns.as_ref(), column_widths.as_ref()) { if *count as usize != widths.len() { return Err("columns must match column_widths length".into()); } }
1529
+ if break_type.as_ref().is_some_and(|v| !matches!(v.as_str(), "nextPage" | "continuous" | "oddPage" | "evenPage" | "nextColumn")) { return Err("unsupported section break_type".into()); }
1530
+ if page_number_format.as_ref().is_some_and(|v| !matches!(v.as_str(), "decimal" | "upperRoman" | "lowerRoman" | "upperLetter" | "lowerLetter")) { return Err("unsupported page_number_format".into()); }
1531
+ if page_number_start == &Some(0) { return Err("page_number_start must be positive".into()); }
1532
+ if page_number_start.is_some() && page_number_continue == &Some(true) { return Err("page_number_start conflicts with page_number_continue".into()); }
1533
+ if page_size.is_none() && width.is_none() && orientation.is_none() && columns.is_none() && column_spacing.is_none() && column_widths.is_none() && column_separator.is_none() && break_type.is_none() && page_number_format.is_none() && page_number_start.is_none() && page_number_continue.is_none() { return Err("set_section_layout requires a property".into()); }
1534
+ }
1208
1535
  EditOp::SetPageMargins {
1209
1536
  section,
1210
1537
  top,
@@ -1271,7 +1598,8 @@ fn normalize_op(op: &mut EditOp) -> Result<(), String> {
1271
1598
  ..
1272
1599
  }
1273
1600
  | EditOp::SetEvenAndOddHeaders { .. } => {}
1274
- EditOp::SetHeader { section, .. }
1601
+ EditOp::SetFirstPageMode { section, .. }
1602
+ | EditOp::SetHeader { section, .. }
1275
1603
  | EditOp::SetFooter { section, .. }
1276
1604
  | EditOp::ClearHeader { section, .. }
1277
1605
  | EditOp::ClearFooter { section, .. } => {
@@ -1563,9 +1891,8 @@ fn fold_typed(
1563
1891
  )),
1564
1892
  );
1565
1893
  }
1566
- if let EditOp::InsertParagraph {
1567
- alias: Some(alias), ..
1568
- } = op
1894
+ if let EditOp::InsertParagraph { alias: Some(alias), .. }
1895
+ | EditOp::InsertTable { alias: Some(alias), .. } = op
1569
1896
  {
1570
1897
  if aliases.contains_key(alias) {
1571
1898
  attach_affected(&state, &mut reports, &affected_ids);
@@ -1590,6 +1917,9 @@ fn fold_typed(
1590
1917
  )),
1591
1918
  );
1592
1919
  }
1920
+ if let EditOp::InsertTable { .. } = op {
1921
+ aliases.insert(alias.clone(), Value::String(format!("${alias}")));
1922
+ }
1593
1923
  if let Some(id) = outcome
1594
1924
  .result
1595
1925
  .as_ref()
@@ -1717,11 +2047,19 @@ fn operation_name(op: &EditOp) -> &'static str {
1717
2047
  EditOp::ReplaceText { .. } => "replace_text",
1718
2048
  EditOp::ReviseText { .. } => "revise_text",
1719
2049
  EditOp::ReplaceParagraph { .. } => "replace_paragraph",
2050
+ EditOp::EditTable { .. } => "edit_table",
2051
+ EditOp::InsertTable { .. } => "insert_table",
2052
+ EditOp::FormatTable { .. } => "format_table",
2053
+ EditOp::FormatRow { .. } => "format_row",
2054
+ EditOp::FormatCell { .. } => "format_cell",
1720
2055
  EditOp::FormatText { .. } => "format_text",
1721
2056
  EditOp::FormatParagraph { .. } => "format_paragraph",
2057
+ EditOp::SetList { .. } => "set_list",
1722
2058
  EditOp::InsertParagraph { .. } => "insert_paragraph",
1723
2059
  EditOp::DeleteParagraphs { .. } => "delete_paragraphs",
1724
2060
  EditOp::SetPageMargins { .. } => "set_page_margins",
2061
+ EditOp::SetSectionLayout { .. } => "set_section_layout",
2062
+ EditOp::SetFirstPageMode { .. } => "set_first_page_mode",
1725
2063
  EditOp::SetEvenAndOddHeaders { .. } => "set_even_and_odd_headers",
1726
2064
  EditOp::SetHeader { .. } => "set_header",
1727
2065
  EditOp::SetFooter { .. } => "set_footer",
@@ -1758,6 +2096,7 @@ fn resolve_typed_aliases(
1758
2096
  | EditOp::ReplaceText { at, .. }
1759
2097
  | EditOp::ReplaceParagraph { at, .. }
1760
2098
  | EditOp::FormatText { at, .. }
2099
+ | EditOp::InsertTable { at, .. }
1761
2100
  | EditOp::FormatParagraph { at, .. }
1762
2101
  | EditOp::CommentAdd { at, .. } => resolve(at)?,
1763
2102
  EditOp::ReviseText { at: Some(at), .. } => resolve(at)?,
@@ -1766,7 +2105,7 @@ fn resolve_typed_aliases(
1766
2105
  resolve(addr)?;
1767
2106
  }
1768
2107
  }
1769
- EditOp::DeleteParagraphs { at } => {
2108
+ EditOp::DeleteParagraphs { at } | EditOp::SetList { at, .. } => {
1770
2109
  for address in at {
1771
2110
  resolve(address)?;
1772
2111
  }
@@ -1965,6 +2304,7 @@ fn typed_read(input: Option<&[u8]>, kind: &str, view: Option<&str>) -> crate::ou
1965
2304
  let (markup, css) = crate::html::compact::agent_html(&rendered.html);
1966
2305
  map.insert("markup".into(), serde_json::json!(markup));
1967
2306
  map.insert("css".into(), serde_json::json!(css));
2307
+ map.insert("tables".into(), crate::commands::tables::inventory(&state.xml));
1968
2308
  map.insert("styles".into(), serde_json::json!(crate::html::styles::Styles::load(&state.package).catalog()));
1969
2309
  if !equations.is_empty() {
1970
2310
  map.insert("equations".into(), serde_json::json!(equations));
@@ -2068,6 +2408,7 @@ fn typed_read(input: Option<&[u8]>, kind: &str, view: Option<&str>) -> crate::ou
2068
2408
  "assets".into(),
2069
2409
  serde_json::json!(crate::html::assets::manifest(&state.package, false)),
2070
2410
  );
2411
+ map.insert("tables".into(), crate::commands::tables::inventory(&state.xml));
2071
2412
  map.insert("html".into(), Value::String(rendered.html));
2072
2413
  }
2073
2414
  if let Some(map) = result.result.as_mut().and_then(Value::as_object_mut) {