docxdriver 0.4.0__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {docxdriver-0.4.0 → docxdriver-0.4.2}/Cargo.lock +16 -6
- {docxdriver-0.4.0 → docxdriver-0.4.2}/Cargo.toml +1 -1
- {docxdriver-0.4.0 → docxdriver-0.4.2}/PKG-INFO +1 -1
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/Cargo.toml +4 -3
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/api.rs +16 -1
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/comments.rs +33 -10
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/equations.rs +4 -2
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/headers.rs +8 -4
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/layout.rs +14 -4
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/mod.rs +24 -2
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/readonly.rs +4 -1
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/revisions.rs +145 -17
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/tables.rs +16 -6
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/typed.rs +172 -69
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/document.rs +32 -22
- docxdriver-0.4.2/crates/docxdriver-core/src/integrity/mod.rs +1519 -0
- docxdriver-0.4.2/crates/docxdriver-core/src/integrity/schema.rs +512 -0
- docxdriver-0.4.2/crates/docxdriver-core/src/integrity/tree.rs +503 -0
- docxdriver-0.4.2/crates/docxdriver-core/src/integrity/wml-model.json +1 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/lib.rs +1 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/package.rs +54 -18
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/segments.rs +338 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/README.md +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/LICENSE +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/README.md +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/assets/blank.docx +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/lists.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/reading.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/sections.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/commands/table_structure.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/facade.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/assets.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/build.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/chrome.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/compact.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/ctx.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/input.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/mathml_omml.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/mod.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/omml_latex.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/omml_mathml.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/parse.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/render.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/styles.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/html/surface.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/media.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-core/src/outcome.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/.gitignore +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/Cargo.toml +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/README.md +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/python/docxdriver/__init__.py +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/python/docxdriver/document.py +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/python/docxdriver/plan.py +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/src/lib.rs +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/tests/test_document.py +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/crates/docxdriver-python/tests/test_plan.py +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/pyproject.toml +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/python/docxdriver/__init__.py +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/python/docxdriver/document.py +0 -0
- {docxdriver-0.4.0 → docxdriver-0.4.2}/python/docxdriver/plan.py +0 -0
|
@@ -416,7 +416,7 @@ dependencies = [
|
|
|
416
416
|
|
|
417
417
|
[[package]]
|
|
418
418
|
name = "docxdriver-cli"
|
|
419
|
-
version = "0.4.
|
|
419
|
+
version = "0.4.2"
|
|
420
420
|
dependencies = [
|
|
421
421
|
"clap",
|
|
422
422
|
"docxdriver-core",
|
|
@@ -427,10 +427,11 @@ dependencies = [
|
|
|
427
427
|
|
|
428
428
|
[[package]]
|
|
429
429
|
name = "docxdriver-core"
|
|
430
|
-
version = "0.4.
|
|
430
|
+
version = "0.4.2"
|
|
431
431
|
dependencies = [
|
|
432
432
|
"hex",
|
|
433
433
|
"latex2mathml",
|
|
434
|
+
"quick-xml",
|
|
434
435
|
"regex-lite",
|
|
435
436
|
"schemars 0.8.22",
|
|
436
437
|
"serde",
|
|
@@ -444,7 +445,7 @@ dependencies = [
|
|
|
444
445
|
|
|
445
446
|
[[package]]
|
|
446
447
|
name = "docxdriver-mcp"
|
|
447
|
-
version = "0.4.
|
|
448
|
+
version = "0.4.2"
|
|
448
449
|
dependencies = [
|
|
449
450
|
"axum",
|
|
450
451
|
"cap-std",
|
|
@@ -462,14 +463,14 @@ dependencies = [
|
|
|
462
463
|
|
|
463
464
|
[[package]]
|
|
464
465
|
name = "docxdriver-monty-repl"
|
|
465
|
-
version = "0.4.
|
|
466
|
+
version = "0.4.2"
|
|
466
467
|
dependencies = [
|
|
467
468
|
"docxdriver-core",
|
|
468
469
|
]
|
|
469
470
|
|
|
470
471
|
[[package]]
|
|
471
472
|
name = "docxdriver-python"
|
|
472
|
-
version = "0.4.
|
|
473
|
+
version = "0.4.2"
|
|
473
474
|
dependencies = [
|
|
474
475
|
"docxdriver-core",
|
|
475
476
|
"pyo3",
|
|
@@ -479,7 +480,7 @@ dependencies = [
|
|
|
479
480
|
|
|
480
481
|
[[package]]
|
|
481
482
|
name = "docxdriver-wasm"
|
|
482
|
-
version = "0.4.
|
|
483
|
+
version = "0.4.2"
|
|
483
484
|
dependencies = [
|
|
484
485
|
"docxdriver-core",
|
|
485
486
|
"js-sys",
|
|
@@ -1188,6 +1189,15 @@ dependencies = [
|
|
|
1188
1189
|
"syn 2.0.118",
|
|
1189
1190
|
]
|
|
1190
1191
|
|
|
1192
|
+
[[package]]
|
|
1193
|
+
name = "quick-xml"
|
|
1194
|
+
version = "0.42.0"
|
|
1195
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1196
|
+
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
|
1197
|
+
dependencies = [
|
|
1198
|
+
"memchr",
|
|
1199
|
+
]
|
|
1200
|
+
|
|
1191
1201
|
[[package]]
|
|
1192
1202
|
name = "quote"
|
|
1193
1203
|
version = "1.0.46"
|
|
@@ -8,9 +8,9 @@ repository.workspace = true
|
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
keywords = ["docx", "ooxml", "word", "document", "track-changes"]
|
|
10
10
|
categories = ["text-processing", "parser-implementations"]
|
|
11
|
-
# Library sources
|
|
12
|
-
# schema generator stay in the repository.
|
|
13
|
-
include = ["/src/**/*.rs", "!/src/bin/**", "/assets/blank.docx", "/README.md", "/LICENSE"]
|
|
11
|
+
# Library sources, the runtime template and the generated schema model
|
|
12
|
+
# only; tests, examples and the schema generator stay in the repository.
|
|
13
|
+
include = ["/src/**/*.rs", "!/src/bin/**", "/src/integrity/wml-model.json", "/assets/blank.docx", "/README.md", "/LICENSE"]
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
16
16
|
xmloxide = { version = "0.4.3", default-features = false }
|
|
@@ -24,5 +24,6 @@ sha2 = "0.10"
|
|
|
24
24
|
hex = "0.4"
|
|
25
25
|
toml = "0.8"
|
|
26
26
|
schemars = "0.8"
|
|
27
|
+
quick-xml = "0.42.0"
|
|
27
28
|
|
|
28
29
|
[dev-dependencies]
|
|
@@ -1957,7 +1957,22 @@ fn fold_typed(
|
|
|
1957
1957
|
crate::outcome::Outcome::ok("plan applied", serde_json::json!({})),
|
|
1958
1958
|
&ctx,
|
|
1959
1959
|
);
|
|
1960
|
-
|
|
1960
|
+
if out.status != crate::outcome::Status::Ok {
|
|
1961
|
+
return (
|
|
1962
|
+
report.clone(),
|
|
1963
|
+
None,
|
|
1964
|
+
Some((diag("package_write", out.summary, None), report)),
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
// Review and commit apply the same gate to the same bytes, so a plan
|
|
1968
|
+
// that reviews clean commits clean.
|
|
1969
|
+
if let Some(bytes) = &out.bytes {
|
|
1970
|
+
if let Err(failure) = crate::integrity::gate(input, bytes) {
|
|
1971
|
+
let diagnostic = diag("integrity_regression", failure.summary(), None);
|
|
1972
|
+
return (report.clone(), None, Some((diagnostic, report)));
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
(report, if commit { out.bytes } else { None }, None)
|
|
1961
1976
|
}
|
|
1962
1977
|
/// When a selector matched nothing but matches the same paragraph as it was
|
|
1963
1978
|
/// before the plan, name the earlier operations that changed that text:
|
|
@@ -28,7 +28,6 @@ const EXTENDED_PART: &str = "word/commentsExtended.xml";
|
|
|
28
28
|
const IDS_PART: &str = "word/commentsIds.xml";
|
|
29
29
|
const EXTENSIBLE_PART: &str = "word/commentsExtensible.xml";
|
|
30
30
|
const CONTENT_TYPES_PART: &str = "[Content_Types].xml";
|
|
31
|
-
const DOCUMENT_RELS_PART: &str = "word/_rels/document.xml.rels";
|
|
32
31
|
|
|
33
32
|
const W15_NS: &str = "http://schemas.microsoft.com/office/word/2012/wordml";
|
|
34
33
|
const W14_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordml";
|
|
@@ -104,15 +103,18 @@ fn register_part(
|
|
|
104
103
|
}
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
//
|
|
108
|
-
let
|
|
106
|
+
// The main document part's relationship.
|
|
107
|
+
let rels_part = super::document_rels_part(package);
|
|
108
|
+
let mut rels = parse_part(package, &rels_part, "")?;
|
|
109
109
|
let root = rels
|
|
110
110
|
.doc
|
|
111
111
|
.root_element()
|
|
112
112
|
.ok_or_else(|| Outcome::error("malformed document.xml.rels"))?;
|
|
113
113
|
let target = part.strip_prefix("word/").unwrap_or(part);
|
|
114
114
|
let exists = rels.doc.children(root).any(|child| {
|
|
115
|
-
rels.is_local(child, "Relationship")
|
|
115
|
+
rels.is_local(child, "Relationship")
|
|
116
|
+
&& rels.doc.attribute(child, "Target") == Some(target)
|
|
117
|
+
&& rels.doc.attribute(child, "Type") == Some(rel_type)
|
|
116
118
|
});
|
|
117
119
|
if !exists {
|
|
118
120
|
let mut max_rid = 0u64;
|
|
@@ -132,7 +134,7 @@ fn register_part(
|
|
|
132
134
|
rels.doc.set_attribute(node, "Type", rel_type);
|
|
133
135
|
rels.doc.set_attribute(node, "Target", target);
|
|
134
136
|
rels.doc.append_child(root, node);
|
|
135
|
-
package.set(
|
|
137
|
+
package.set(&rels_part, rels.serialize());
|
|
136
138
|
}
|
|
137
139
|
Ok(())
|
|
138
140
|
}
|
|
@@ -279,14 +281,35 @@ fn next_para_id(records: &[CommentRecord], extra_occupied: &HashSet<u64>) -> u64
|
|
|
279
281
|
}
|
|
280
282
|
}
|
|
281
283
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
+
/// Paragraph ids in use: the in-memory document's (it may carry repaired or
|
|
285
|
+
/// new ids not yet written) and every `paraId` attribute in any XML part —
|
|
286
|
+
/// other comment paragraphs, headers, footers and notes. A paragraph id
|
|
287
|
+
/// names one paragraph across the whole document.
|
|
288
|
+
fn occupied_para_ids(package: &Package, xml: &DocxXml) -> HashSet<u64> {
|
|
289
|
+
static PARA_ID: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
|
|
290
|
+
let pattern = PARA_ID.get_or_init(|| {
|
|
291
|
+
regex_lite::Regex::new(r#"paraId(?:Parent)?\s*=\s*["']([0-9A-Fa-f]{8})["']"#).unwrap()
|
|
292
|
+
});
|
|
293
|
+
let mut occupied: HashSet<u64> = xml
|
|
294
|
+
.all_paragraphs()
|
|
284
295
|
.into_iter()
|
|
285
296
|
.filter_map(|node| {
|
|
286
297
|
xml.para_id(node)
|
|
287
298
|
.and_then(|value| u64::from_str_radix(value, 16).ok())
|
|
288
299
|
})
|
|
289
|
-
.collect()
|
|
300
|
+
.collect();
|
|
301
|
+
for (name, bytes) in package.parts() {
|
|
302
|
+
if !name.to_ascii_lowercase().ends_with(".xml") {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
let text = String::from_utf8_lossy(bytes);
|
|
306
|
+
occupied.extend(
|
|
307
|
+
pattern
|
|
308
|
+
.captures_iter(&text)
|
|
309
|
+
.filter_map(|c| u64::from_str_radix(&c[1], 16).ok()),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
occupied
|
|
290
313
|
}
|
|
291
314
|
|
|
292
315
|
/// Deterministic 8-hex durable id (FNV-1a over identifying fields), clamped
|
|
@@ -447,7 +470,7 @@ pub(crate) fn anchor_comment_at(
|
|
|
447
470
|
let comments = parse_part(package, COMMENTS_PART, EMPTY_COMMENTS)?;
|
|
448
471
|
let records = comment_records(&comments);
|
|
449
472
|
let id = next_comment_id(&records);
|
|
450
|
-
let para_id = format!("{:08X}", next_para_id(&records, &
|
|
473
|
+
let para_id = format!("{:08X}", next_para_id(&records, &occupied_para_ids(package, xml)));
|
|
451
474
|
|
|
452
475
|
insert_anchor_markers(xml, paragraph, start, end, id)?;
|
|
453
476
|
append_comment_record(package, id, ¶_id, None, author, date, text)?;
|
|
@@ -736,7 +759,7 @@ pub fn reply_comment_typed(
|
|
|
736
759
|
let parent_para_id = parent.para_id.clone();
|
|
737
760
|
let parent_id = parent.id.clone();
|
|
738
761
|
let id = next_comment_id(&records);
|
|
739
|
-
let para_id = format!("{:08X}", next_para_id(&records, &
|
|
762
|
+
let para_id = format!("{:08X}", next_para_id(&records, &occupied_para_ids(package, xml)));
|
|
740
763
|
append_comment_record(
|
|
741
764
|
package,
|
|
742
765
|
id,
|
|
@@ -101,6 +101,7 @@ pub(super) fn replace(
|
|
|
101
101
|
xml.doc.insert_before(target, holder);
|
|
102
102
|
xml.doc.detach(target);
|
|
103
103
|
xml.doc.append_child(deletion, target);
|
|
104
|
+
crate::segments::mark_deleted_leaves(xml, target, true);
|
|
104
105
|
let ins_id = xml.next_revision_id();
|
|
105
106
|
xml.doc.set_attribute(holder, "w:id", &ins_id.to_string());
|
|
106
107
|
xml.doc.set_attribute(holder, "w:author", author);
|
|
@@ -110,7 +111,7 @@ pub(super) fn replace(
|
|
|
110
111
|
xml.doc.detach(child);
|
|
111
112
|
xml.doc.insert_before(target, child);
|
|
112
113
|
}
|
|
113
|
-
xml
|
|
114
|
+
crate::segments::remove_content(xml, target);
|
|
114
115
|
xml.doc.remove_node(holder);
|
|
115
116
|
}
|
|
116
117
|
Ok((
|
|
@@ -156,8 +157,9 @@ pub(super) fn delete(
|
|
|
156
157
|
xml.doc.insert_before(target, deletion);
|
|
157
158
|
xml.doc.detach(target);
|
|
158
159
|
xml.doc.append_child(deletion, target);
|
|
160
|
+
crate::segments::mark_deleted_leaves(xml, target, true);
|
|
159
161
|
} else {
|
|
160
|
-
xml
|
|
162
|
+
crate::segments::remove_content(xml, target);
|
|
161
163
|
}
|
|
162
164
|
Ok((
|
|
163
165
|
format!("deleted equation {} in paragraph {at}", index + 1),
|
|
@@ -13,7 +13,6 @@ use crate::package::Package;
|
|
|
13
13
|
use super::layout::ensure_sect_pr;
|
|
14
14
|
|
|
15
15
|
const CONTENT_TYPES_PART: &str = "[Content_Types].xml";
|
|
16
|
-
const DOCUMENT_RELS_PART: &str = "word/_rels/document.xml.rels";
|
|
17
16
|
const SETTINGS_CT: &str =
|
|
18
17
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
|
|
19
18
|
const SETTINGS_REL: &str =
|
|
@@ -73,13 +72,18 @@ fn ensure_document_rel(
|
|
|
73
72
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
|
74
73
|
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>",
|
|
75
74
|
);
|
|
76
|
-
let
|
|
75
|
+
let rels_part = super::document_rels_part(package);
|
|
76
|
+
let mut rels = parse_part(package, &rels_part, default)?;
|
|
77
77
|
let root = rels
|
|
78
78
|
.doc
|
|
79
79
|
.root_element()
|
|
80
80
|
.ok_or_else(|| Outcome::error("malformed document.xml.rels"))?;
|
|
81
|
+
// Reuse only a relationship of the same type: a header id that names a
|
|
82
|
+
// footer relationship makes Word refuse the document.
|
|
81
83
|
if let Some(existing) = rels.doc.children(root).find(|&child| {
|
|
82
|
-
rels.is_local(child, "Relationship")
|
|
84
|
+
rels.is_local(child, "Relationship")
|
|
85
|
+
&& rels.doc.attribute(child, "Target") == Some(target)
|
|
86
|
+
&& rels.doc.attribute(child, "Type") == Some(rel_type)
|
|
83
87
|
}) {
|
|
84
88
|
return Ok(rels
|
|
85
89
|
.doc
|
|
@@ -104,7 +108,7 @@ fn ensure_document_rel(
|
|
|
104
108
|
rels.doc.set_attribute(node, "Type", rel_type);
|
|
105
109
|
rels.doc.set_attribute(node, "Target", target);
|
|
106
110
|
rels.doc.append_child(root, node);
|
|
107
|
-
package.set(
|
|
111
|
+
package.set(&rels_part, rels.serialize());
|
|
108
112
|
Ok(id)
|
|
109
113
|
}
|
|
110
114
|
|
|
@@ -188,10 +188,13 @@ fn ensure_property_child(
|
|
|
188
188
|
.iter()
|
|
189
189
|
.position(|name| *name == local)
|
|
190
190
|
.map_or(&[][..], |index| &sequence[index + 1..]);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
// Office extension elements (w14 text effects and the like) close the
|
|
192
|
+
// base sequence, before only the trailing revision record.
|
|
193
|
+
let trailing = sequence.last() == Some(&local);
|
|
194
|
+
let successor = xml.doc.children(parent).find(|&child| {
|
|
195
|
+
successors.iter().any(|name| xml.is_w(child, name))
|
|
196
|
+
|| (!trailing && xml.doc.is_element(child) && is_extension(xml, child))
|
|
197
|
+
});
|
|
195
198
|
if let Some(next) = successor {
|
|
196
199
|
xml.doc.insert_before(next, node);
|
|
197
200
|
} else {
|
|
@@ -200,6 +203,13 @@ fn ensure_property_child(
|
|
|
200
203
|
node
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
/// An element outside WordprocessingML: an Office extension, or the markup
|
|
207
|
+
/// compatibility wrapper that carries one.
|
|
208
|
+
fn is_extension(xml: &DocxXml, node: NodeId) -> bool {
|
|
209
|
+
let ns = xml.doc.node_namespace(node);
|
|
210
|
+
ns.is_some() && ns != Some(crate::document::W_NS)
|
|
211
|
+
}
|
|
212
|
+
|
|
203
213
|
pub(crate) fn set_child_val(xml: &mut DocxXml, parent: NodeId, local: &str, val: &str) {
|
|
204
214
|
let node = if matches!(local, "pStyle" | "jc") {
|
|
205
215
|
ensure_ppr_child(xml, parent, local)
|
|
@@ -27,6 +27,8 @@ pub struct ExecCtx {
|
|
|
27
27
|
pub now: String,
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const STRICT_W_NS: &str = "http://purl.oclc.org/ooxml/wordprocessingml/main";
|
|
31
|
+
|
|
30
32
|
/// The parsed working set the operation fold runs over: one unzip + one
|
|
31
33
|
/// document.xml parse per typed plan, however many operations follow.
|
|
32
34
|
pub(crate) struct WorkState {
|
|
@@ -62,6 +64,14 @@ impl WorkState {
|
|
|
62
64
|
.map_err(|error| Outcome::error(error.to_string()))?;
|
|
63
65
|
DocxXml::parse(bytes).map_err(Outcome::error)?
|
|
64
66
|
};
|
|
67
|
+
// ISO Strict markup lives in other namespaces: the engine would read
|
|
68
|
+
// it as empty and write Transitional markup into it.
|
|
69
|
+
if xml.doc.root_element().and_then(|root| xml.doc.node_namespace(root)) == Some(STRICT_W_NS) {
|
|
70
|
+
return Err(Outcome::error(
|
|
71
|
+
"this document is saved as Strict Open XML, which docxdriver does not support; \
|
|
72
|
+
save it as a standard Word Document (.docx) and try again",
|
|
73
|
+
));
|
|
74
|
+
}
|
|
65
75
|
let table_positions = table_positions(&xml);
|
|
66
76
|
let table_handles = table_positions.iter().map(|(node, address)| (address.clone(), *node)).collect();
|
|
67
77
|
Ok(WorkState {
|
|
@@ -93,6 +103,16 @@ pub(crate) fn table_positions(xml: &DocxXml) -> HashMap<xmloxide::tree::NodeId,
|
|
|
93
103
|
map
|
|
94
104
|
}
|
|
95
105
|
|
|
106
|
+
/// The relationships part of the main document part (`word/_rels/document.xml.rels`
|
|
107
|
+
/// for the conventional name; a renamed main part has its own).
|
|
108
|
+
pub(crate) fn document_rels_part(package: &crate::package::Package) -> String {
|
|
109
|
+
let main = package.document_part_name();
|
|
110
|
+
match main.rsplit_once('/') {
|
|
111
|
+
Some((dir, file)) => format!("{dir}/_rels/{file}.rels"),
|
|
112
|
+
None => format!("_rels/{main}.rels"),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
/// sha256 of the exact input package bytes.
|
|
97
117
|
pub(crate) fn package_sha256(bytes: &[u8]) -> [u8; 32] {
|
|
98
118
|
use sha2::{Digest, Sha256};
|
|
@@ -100,14 +120,16 @@ pub(crate) fn package_sha256(bytes: &[u8]) -> [u8; 32] {
|
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
/// Attach the serialized package to a successful outcome when something
|
|
103
|
-
/// actually mutated
|
|
123
|
+
/// actually mutated. The one zip write. A dry run builds the bytes too, so
|
|
124
|
+
/// review runs the same integrity gate as commit; callers withhold them.
|
|
104
125
|
///
|
|
105
126
|
/// Every engine write persists paragraph identity first: existing valid
|
|
106
127
|
/// `w14:paraId` values are untouched, while missing/invalid/duplicate and
|
|
107
128
|
/// engine-created paragraphs receive the same deterministic values inspection
|
|
108
129
|
/// showed — one atomic write of repairs plus content operations.
|
|
109
130
|
pub(crate) fn emit(mut state: WorkState, outcome: Outcome, ctx: &ExecCtx) -> Outcome {
|
|
110
|
-
|
|
131
|
+
let _ = ctx;
|
|
132
|
+
if outcome.status != crate::outcome::Status::Ok || !state.mutated {
|
|
111
133
|
return outcome;
|
|
112
134
|
}
|
|
113
135
|
state.flush_lists();
|
|
@@ -219,7 +219,10 @@ pub fn create(command: &Value, ctx: &ExecCtx) -> Outcome {
|
|
|
219
219
|
return Outcome::ok(summary, result);
|
|
220
220
|
}
|
|
221
221
|
match package.to_bytes() {
|
|
222
|
-
Ok(bytes) =>
|
|
222
|
+
Ok(bytes) => match crate::integrity::gate_created(&bytes) {
|
|
223
|
+
Ok(()) => Outcome::ok_bytes(summary, result, bytes),
|
|
224
|
+
Err(failure) => Outcome::error(failure.summary()),
|
|
225
|
+
},
|
|
223
226
|
Err(error) => Outcome::error(error.to_string()),
|
|
224
227
|
}
|
|
225
228
|
}
|
|
@@ -8,7 +8,7 @@ use xmloxide::tree::NodeId;
|
|
|
8
8
|
use crate::document::DocxXml;
|
|
9
9
|
use crate::outcome::Outcome;
|
|
10
10
|
use crate::package::Package;
|
|
11
|
-
use crate::segments::{all_text, build_segments,
|
|
11
|
+
use crate::segments::{all_text, build_segments, mark_deleted_leaves, remove_content};
|
|
12
12
|
|
|
13
13
|
use super::readonly::plural;
|
|
14
14
|
|
|
@@ -357,16 +357,25 @@ fn resolve_entry(xml: &mut DocxXml, entry: &RevisionEntry, accept: bool) -> Resu
|
|
|
357
357
|
}
|
|
358
358
|
sibling
|
|
359
359
|
};
|
|
360
|
+
// The joined paragraph ends at the next paragraph's mark, so that
|
|
361
|
+
// mark's pPr and identity win, as in Word: this paragraph's
|
|
362
|
+
// content moves to the front of the next one and this one goes.
|
|
363
|
+
// <p A>Mine.¶(ins)</p><p B, list>item one¶</p>
|
|
364
|
+
// -> <p B, list>Mine.item one¶</p>
|
|
360
365
|
if let Some(next) = next {
|
|
361
|
-
let
|
|
366
|
+
let anchor = xml.doc.children(next).find(|&c| !xml.is_w(c, "pPr"));
|
|
367
|
+
let children: Vec<NodeId> = xml.doc.children(entry.paragraph).collect();
|
|
362
368
|
for child in children {
|
|
363
369
|
if xml.is_w(child, "pPr") {
|
|
364
370
|
continue;
|
|
365
371
|
}
|
|
366
372
|
xml.doc.detach(child);
|
|
367
|
-
|
|
373
|
+
match anchor {
|
|
374
|
+
Some(anchor) => xml.doc.insert_before(anchor, child),
|
|
375
|
+
None => xml.doc.append_child(next, child),
|
|
376
|
+
}
|
|
368
377
|
}
|
|
369
|
-
xml.doc.remove_node(
|
|
378
|
+
xml.doc.remove_node(entry.paragraph);
|
|
370
379
|
}
|
|
371
380
|
}
|
|
372
381
|
return Ok(());
|
|
@@ -381,24 +390,15 @@ fn resolve_entry(xml: &mut DocxXml, entry: &RevisionEntry, accept: bool) -> Resu
|
|
|
381
390
|
xml.doc.detach(child);
|
|
382
391
|
xml.doc.insert_before(entry.node, child);
|
|
383
392
|
if !accept {
|
|
384
|
-
// Rejected delete: its w:delText leaves become live w:t
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
.descendants(child)
|
|
388
|
-
.filter(|&n| xml.is_w(n, "delText"))
|
|
389
|
-
.collect();
|
|
390
|
-
for leaf in leaves {
|
|
391
|
-
let name = rename_for(xml, leaf, false);
|
|
392
|
-
xml.doc.rename_element(leaf, name);
|
|
393
|
-
}
|
|
393
|
+
// Rejected delete: its w:delText leaves become live w:t
|
|
394
|
+
// again, and w:delInstrText field codes w:instrText.
|
|
395
|
+
mark_deleted_leaves(xml, child, false);
|
|
394
396
|
}
|
|
395
397
|
}
|
|
396
398
|
xml.doc.remove_node(entry.node);
|
|
397
399
|
}
|
|
398
400
|
// Rejected insert / accepted delete: the content goes.
|
|
399
|
-
(true, false) | (false, true) =>
|
|
400
|
-
xml.doc.remove_node(entry.node);
|
|
401
|
-
}
|
|
401
|
+
(true, false) | (false, true) => remove_content(xml, entry.node),
|
|
402
402
|
}
|
|
403
403
|
Ok(())
|
|
404
404
|
}
|
|
@@ -519,6 +519,27 @@ fn accept_reject_all_in_xml(xml: &mut DocxXml, accept: bool) -> Result<usize, Ou
|
|
|
519
519
|
resolve_entry(xml, &entry, accept)?;
|
|
520
520
|
count += 1;
|
|
521
521
|
}
|
|
522
|
+
// Tracked cell insertions and deletions: the marker sits in the cell's
|
|
523
|
+
// tcPr, and the cell goes on a rejected insert or an accepted delete.
|
|
524
|
+
let mut shortened: Vec<NodeId> = Vec::new();
|
|
525
|
+
loop {
|
|
526
|
+
let marker = scope(xml).and_then(|root| {
|
|
527
|
+
xml.doc.descendants(root).find(|&n| {
|
|
528
|
+
(xml.is_w(n, "cellIns") || xml.is_w(n, "cellDel"))
|
|
529
|
+
&& xml.doc.parent(n).is_some_and(|p| xml.is_w(p, "tcPr"))
|
|
530
|
+
})
|
|
531
|
+
});
|
|
532
|
+
let Some(marker) = marker else { break };
|
|
533
|
+
count += 1;
|
|
534
|
+
let cell = xml.doc.parent(marker).and_then(|p| xml.doc.parent(p));
|
|
535
|
+
if accept != xml.is_w(marker, "cellDel") {
|
|
536
|
+
xml.doc.remove_node(marker);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if let Some(row) = cell.and_then(|cell| remove_cell(xml, cell)) {
|
|
540
|
+
shortened.push(row);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
522
543
|
// One record at a time, re-scanning between resolutions: rejecting a
|
|
523
544
|
// record swaps subtrees around, which can relocate or remove records
|
|
524
545
|
// collected earlier (e.g. an rPrChange nested in a pPrChange snapshot).
|
|
@@ -534,12 +555,119 @@ fn accept_reject_all_in_xml(xml: &mut DocxXml, accept: bool) -> Result<usize, Ou
|
|
|
534
555
|
resolve_property_change(xml, node, accept);
|
|
535
556
|
count += 1;
|
|
536
557
|
}
|
|
558
|
+
// Rows that lost a cell are padded with gridAfter against the settled
|
|
559
|
+
// grid, the way Word writes a short row.
|
|
560
|
+
for row in shortened {
|
|
561
|
+
pad_short_row(xml, row);
|
|
562
|
+
}
|
|
537
563
|
if let Some(root) = scope(xml) {
|
|
538
564
|
strip_move_range_markers_in(xml, root, None);
|
|
539
565
|
}
|
|
540
566
|
Ok(count)
|
|
541
567
|
}
|
|
542
568
|
|
|
569
|
+
/// The cells of `row`, through any content-control wrappers, but not those
|
|
570
|
+
/// of tables nested in its cells.
|
|
571
|
+
fn row_cells(xml: &DocxXml, row: NodeId) -> Vec<NodeId> {
|
|
572
|
+
xml.doc
|
|
573
|
+
.descendants(row)
|
|
574
|
+
.filter(|&n| {
|
|
575
|
+
xml.is_w(n, "tc")
|
|
576
|
+
&& xml.doc.ancestors(n).skip(1).find(|&a| xml.is_w(a, "tr")) == Some(row)
|
|
577
|
+
})
|
|
578
|
+
.collect()
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/// Remove a table cell. A row left without cells goes too, and a table
|
|
582
|
+
/// left without rows becomes an empty paragraph. Returns the row when it
|
|
583
|
+
/// remains, now short of its grid.
|
|
584
|
+
fn remove_cell(xml: &mut DocxXml, cell: NodeId) -> Option<NodeId> {
|
|
585
|
+
let row = xml.doc.ancestors(cell).skip(1).find(|&a| xml.is_w(a, "tr"))?;
|
|
586
|
+
// A content control holding only this cell goes with it.
|
|
587
|
+
let mut target = cell;
|
|
588
|
+
while let Some(parent) = xml.doc.parent(target) {
|
|
589
|
+
let wrapper = xml.is_w(parent, "sdtContent")
|
|
590
|
+
&& xml.doc.children(parent).filter(|&c| xml.doc.is_element(c)).count() == 1;
|
|
591
|
+
if !wrapper {
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
target = xml.doc.parent(parent).unwrap_or(parent);
|
|
595
|
+
}
|
|
596
|
+
xml.doc.remove_node(target);
|
|
597
|
+
if !row_cells(xml, row).is_empty() {
|
|
598
|
+
return Some(row);
|
|
599
|
+
}
|
|
600
|
+
let table = xml.doc.ancestors(row).skip(1).find(|&a| xml.is_w(a, "tbl"));
|
|
601
|
+
xml.doc.remove_node(row);
|
|
602
|
+
if let Some(table) = table {
|
|
603
|
+
let rows = xml
|
|
604
|
+
.doc
|
|
605
|
+
.descendants(table)
|
|
606
|
+
.any(|n| xml.is_w(n, "tr") && xml.doc.ancestors(n).skip(1).find(|&a| xml.is_w(a, "tbl")) == Some(table));
|
|
607
|
+
if !rows {
|
|
608
|
+
let paragraph = xml.doc.create_element("w:p");
|
|
609
|
+
xml.doc.insert_before(table, paragraph);
|
|
610
|
+
xml.doc.remove_node(table);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
None
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/// Raise a row's gridAfter so its cells and padding cover the table grid.
|
|
617
|
+
fn pad_short_row(xml: &mut DocxXml, row: NodeId) {
|
|
618
|
+
let attached = xml.doc.root_element().is_some_and(|root| xml.doc.ancestors(row).any(|a| a == root));
|
|
619
|
+
let Some(table) = xml.doc.ancestors(row).skip(1).find(|&a| xml.is_w(a, "tbl")) else {
|
|
620
|
+
return;
|
|
621
|
+
};
|
|
622
|
+
if !attached {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
let columns = xml
|
|
626
|
+
.child_w(table, "tblGrid")
|
|
627
|
+
.map(|grid| xml.doc.children(grid).filter(|&c| xml.is_w(c, "gridCol")).count())
|
|
628
|
+
.unwrap_or(0) as i64;
|
|
629
|
+
let value = |xml: &DocxXml, parent: Option<NodeId>, name: &str, default: i64| {
|
|
630
|
+
parent
|
|
631
|
+
.and_then(|p| xml.child_w(p, name))
|
|
632
|
+
.and_then(|n| xml.attr(n, "val"))
|
|
633
|
+
.and_then(|v| v.trim().parse::<i64>().ok())
|
|
634
|
+
.unwrap_or(default)
|
|
635
|
+
};
|
|
636
|
+
let trpr = xml.child_w(row, "trPr");
|
|
637
|
+
let after = value(xml, trpr, "gridAfter", 0);
|
|
638
|
+
let mut position = value(xml, trpr, "gridBefore", 0) + after;
|
|
639
|
+
for cell in row_cells(xml, row) {
|
|
640
|
+
position += value(xml, xml.child_w(cell, "tcPr"), "gridSpan", 1);
|
|
641
|
+
}
|
|
642
|
+
if columns == 0 || position >= columns {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
let trpr = match trpr {
|
|
646
|
+
Some(trpr) => trpr,
|
|
647
|
+
None => {
|
|
648
|
+
let trpr = xml.doc.create_element("w:trPr");
|
|
649
|
+
match xml.child_w(row, "tblPrEx") {
|
|
650
|
+
Some(ex) => xml.doc.insert_after(ex, trpr),
|
|
651
|
+
None => match xml.doc.first_child(row) {
|
|
652
|
+
Some(first) => xml.doc.insert_before(first, trpr),
|
|
653
|
+
None => xml.doc.append_child(row, trpr),
|
|
654
|
+
},
|
|
655
|
+
}
|
|
656
|
+
trpr
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
if let Some(old) = xml.child_w(trpr, "gridAfter") {
|
|
660
|
+
xml.doc.remove_node(old);
|
|
661
|
+
}
|
|
662
|
+
let grid_after = xml.doc.create_element("w:gridAfter");
|
|
663
|
+
xml.doc.set_attribute(grid_after, "w:val", &(after + columns - position).to_string());
|
|
664
|
+
// CT_TrPrBase is an unordered choice; the revision records close CT_TrPr.
|
|
665
|
+
match xml.doc.first_child(trpr) {
|
|
666
|
+
Some(first) => xml.doc.insert_before(first, grid_after),
|
|
667
|
+
None => xml.doc.append_child(trpr, grid_after),
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
543
671
|
/* split_boundary(xml, paragraph, offset);
|
|
544
672
|
let segments = build_segments(xml, paragraph);
|
|
545
673
|
|
|
@@ -363,13 +363,23 @@ pub fn validate_row(at: &str, f: &RowFormat) -> Result<(), String> {
|
|
|
363
363
|
}
|
|
364
364
|
Ok(())
|
|
365
365
|
}
|
|
366
|
+
/// The properties element of a table, row or cell. It precedes all content —
|
|
367
|
+
/// including cells wrapped in w:sdt or w:customXml, which `ensure` would not
|
|
368
|
+
/// recognize — and follows only w:tblPrEx in a row.
|
|
366
369
|
fn bag(x: &mut DocxXml, n: NodeId, tag: &str) -> NodeId {
|
|
367
|
-
let
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
370
|
+
if let Some(existing) = x.child_w(n, tag) {
|
|
371
|
+
return existing;
|
|
372
|
+
}
|
|
373
|
+
let props = x.doc.create_element(&format!("w:{tag}"));
|
|
374
|
+
let next = x
|
|
375
|
+
.doc
|
|
376
|
+
.children(n)
|
|
377
|
+
.find(|&c| x.doc.is_element(c) && !(tag == "trPr" && x.is_w(c, "tblPrEx")));
|
|
378
|
+
match next {
|
|
379
|
+
Some(next) => x.doc.insert_before(next, props),
|
|
380
|
+
None => x.doc.append_child(n, props),
|
|
381
|
+
}
|
|
382
|
+
props
|
|
373
383
|
}
|
|
374
384
|
fn snapshot(
|
|
375
385
|
x: &mut DocxXml,
|