- 1
//! The op engine (docs/design/72-openxml-documents.md, P2): each op lands - 2
//! where its anchor points, leaves everything else byte-identical, is - 3
//! confirmed by a re-read, and fails with a repairable message otherwise. - 4
- 5
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 6
- 7
use std::collections::BTreeMap; - 8
use std::io::{Cursor, Read}; - 9
- 10
use vak_ooxml::edit::{self, CellValue, EditContext, OfficeOp, TextValue}; - 11
use vak_ooxml::fixtures; - 12
use vak_ooxml::read::UnitKind; - 13
use vak_ooxml::{Limits, Package}; - 14
- 15
fn context() -> EditContext { - 16
EditContext { - 17
author: "Mira".into(), - 18
date: "2026-09-24T10:00:00Z".into(), - 19
tracked: true, - 20
} - 21
} - 22
- 23
fn apply(bytes: &[u8], ops: Vec<OfficeOp>) -> Result<edit::Applied, edit::EditError> { - 24
edit::apply(bytes, &ops, &context(), Limits::default(), None) - 25
} - 26
- 27
fn part(bytes: &[u8], name: &str) -> String { - 28
let mut package = Package::open(Cursor::new(bytes.to_vec()), Limits::default()).unwrap(); - 29
String::from_utf8(package.read_part(name).unwrap()).unwrap() - 30
} - 31
- 32
/// Raw compressed bytes of every entry except `except`. - 33
fn untouched(bytes: &[u8], except: &[&str]) -> Vec<(String, Vec<u8>)> { - 34
let mut archive = zip::ZipArchive::new(Cursor::new(bytes.to_vec())).unwrap(); - 35
(0..archive.len()) - 36
.filter_map(|index| { - 37
let mut file = archive.by_index_raw(index).unwrap(); - 38
if except.contains(&file.name()) { - 39
return None; - 40
} - 41
let mut raw = Vec::new(); - 42
file.read_to_end(&mut raw).unwrap(); - 43
Some((file.name().to_string(), raw)) - 44
}) - 45
.collect() - 46
} - 47
- 48
fn lines(applied: &edit::Applied) -> String { - 49
applied.document.lines().join("\n") - 50
} - 51
- 52
// ---- Word ---------------------------------------------------------------------- - 53
- 54
#[test] - 55
fn replacing_a_paragraph_is_a_tracked_change_and_touches_nothing_else() { - 56
let source = fixtures::docx(); - 57
let applied = apply( - 58
&source, - 59
vec![OfficeOp::ReplaceParagraphText { - 60
anchor: "p@11".into(), - 61
text: "Growing fast.".into(), - 62
}], - 63
) - 64
.unwrap(); - 65
let text = lines(&applied); - 66
assert!( - 67
text.contains("[p@11] [deleted by Mira: Steady][inserted by Mira: Growing fast]."), - 68
"only the word changes, not the full stop: {text}" - 69
); - 70
assert_eq!(applied.results[0].op, "replace_paragraph_text"); - 71
assert!(applied.results[0].check.starts_with("passed")); - 72
assert_eq!( - 73
untouched(&source, &["word/document.xml"]), - 74
untouched(&applied.bytes, &["word/document.xml"]), - 75
"every other part is copied raw" - 76
); - 77
let before = part(&source, "word/document.xml"); - 78
let after = part(&applied.bytes, "word/document.xml"); - 79
let edited = before - 80
.find("<w:p><w:r><w:t>Steady.</w:t></w:r></w:p>") - 81
.unwrap(); - 82
assert_eq!( - 83
&after[..edited], - 84
&before[..edited], - 85
"bytes before the paragraph are identical" - 86
); - 87
assert!(after.contains(r#"w:author="Mira" w:date="2026-09-24T10:00:00Z""#)); - 88
assert!(after.contains(r#"<w:delText xml:space="preserve">Steady</w:delText>"#)); - 89
} - 90
- 91
#[test] - 92
fn inserting_a_paragraph_uses_a_style_by_name_and_a_stable_new_anchor() { - 93
let applied = apply( - 94
&fixtures::docx(), - 95
vec![ - 96
OfficeOp::AddParagraph { - 97
text: "Details".into(), - 98
style: Some("heading 2".into()), - 99
after: Some("p@1".into()), - 100
}, - 101
OfficeOp::ReplaceParagraphText { - 102
anchor: "p@11".into(), - 103
text: "Still steady.".into(), - 104
}, - 105
], - 106
) - 107
.unwrap(); - 108
let inserted = applied - 109
.document - 110
.units - 111
.iter() - 112
.find(|unit| unit.text.contains("[inserted by Mira: Details]")) - 113
.unwrap(); - 114
assert!( - 115
inserted.anchor.starts_with("p:"), - 116
"new paragraphs get a paraId: {}", - 117
inserted.anchor - 118
); - 119
assert_eq!(inserted.kind, UnitKind::Heading); - 120
assert_eq!(inserted.level, 2); - 121
assert!( - 122
lines(&applied) - 123
.contains("[p@11] [deleted by Mira: Steady][inserted by Mira: Still steady]."), - 124
"p@N anchors from the base read still resolve after an insert in the same call" - 125
); - 126
} - 127
- 128
#[test] - 129
fn inserting_into_a_document_without_w14_declares_it_on_the_root() { - 130
let source = fixtures::word_with( - 131
fixtures::WORD_MAIN, - 132
fixtures::MINIMAL_WORD_BODY, - 133
&[], - 134
&[], - 135
&[], - 136
&[], - 137
); - 138
let applied = apply( - 139
&source, - 140
vec![OfficeOp::AddParagraph { - 141
text: "Second".into(), - 142
style: None, - 143
after: Some("p@1".into()), - 144
}], - 145
) - 146
.unwrap(); - 147
let document = part(&applied.bytes, "word/document.xml"); - 148
assert!( - 149
document.contains(r#"xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml""#) - 150
); - 151
assert!(document.contains(r#"mc:Ignorable="w14""#)); - 152
assert!(lines(&applied).contains("[inserted by Mira: Second]")); - 153
} - 154
- 155
#[test] - 156
fn deleting_a_paragraph_marks_its_text_and_mark_deleted() { - 157
let applied = apply( - 158
&fixtures::docx(), - 159
vec![OfficeOp::DeleteParagraph { - 160
anchor: "p@10".into(), - 161
}], - 162
) - 163
.unwrap(); - 164
assert!( - 165
lines(&applied).contains("[p@10] # [deleted by Mira: Outlook]"), - 166
"{}", - 167
lines(&applied) - 168
); - 169
let document = part(&applied.bytes, "word/document.xml"); - 170
assert!( - 171
document.contains(r#"<w:rPr><w:del w:id="#), - 172
"the paragraph mark is deleted too" - 173
); - 174
} - 175
- 176
#[test] - 177
fn word_refusals_name_the_op_and_the_repair() { - 178
let cases = [ - 179
( - 180
OfficeOp::ReplaceParagraphText { - 181
anchor: "p@11".into(), - 182
text: "[deleted by Mira: Steady.] Up.".into(), - 183
}, - 184
"the reader's marker \"[deleted by\"", - 185
), - 186
( - 187
OfficeOp::DeleteParagraph { - 188
anchor: "p@2".into(), - 189
}, - 190
"tracked-change markup", - 191
), - 192
( - 193
OfficeOp::DeleteParagraph { - 194
anchor: "p@11".into(), - 195
}, - 196
"last paragraph", - 197
), - 198
( - 199
OfficeOp::DeleteParagraph { - 200
anchor: "p@7".into(), - 201
}, - 202
"only paragraph in its table cell", - 203
), - 204
( - 205
OfficeOp::ReplaceParagraphText { - 206
anchor: "p@99".into(), - 207
text: "x".into(), - 208
}, - 209
"no paragraph p@99", - 210
), - 211
( - 212
OfficeOp::AddParagraph { - 213
text: "x".into(), - 214
style: Some("Nonexistent".into()), - 215
after: Some("p@1".into()), - 216
}, - 217
"no paragraph style", - 218
), - 219
( - 220
OfficeOp::SetCells { - 221
sheet: "Budget".into(), - 222
cells: BTreeMap::new(), - 223
}, - 224
"edits an Excel workbook, and this file is a Word document", - 225
), - 226
]; - 227
for (op, expected) in cases { - 228
let name = op.name(); - 229
let error = apply(&fixtures::docx(), vec![op]).unwrap_err(); - 230
let message = error.to_string(); - 231
assert!(message.starts_with(&format!("op 1 ({name})")), "{message}"); - 232
assert!(message.contains(expected), "{message}"); - 233
} - 234
} - 235
- 236
// ---- Excel --------------------------------------------------------------------- - 237
- 238
#[test] - 239
fn setting_cells_keeps_styles_and_marks_formulas_stale() { - 240
let source = fixtures::xlsx(); - 241
let cells = BTreeMap::from([ - 242
("B2".to_string(), CellValue::Number(150.0)), - 243
("C3".to_string(), CellValue::Text("=B3*2".into())), - 244
("D6".to_string(), CellValue::Text("new row".into())), - 245
("E1".to_string(), CellValue::Bool(false)), - 246
("A5".to_string(), CellValue::Text("'=not a formula".into())), - 247
]); - 248
let applied = apply( - 249
&source, - 250
vec![OfficeOp::SetCells { - 251
sheet: "budget".into(), - 252
cells, - 253
}], - 254
) - 255
.unwrap(); - 256
let text = lines(&applied); - 257
assert!(text.contains("A2: Rent | B2: 150"), "{text}"); - 258
assert!(text.contains("C3: =B3*2 [not calculated yet]"), "{text}"); - 259
assert!( - 260
text.contains("B4: =SUM(B2:B3) [cached: 120, stale until recalculated]"), - 261
"a changed input makes cached formula values stale: {text}" - 262
); - 263
assert!(text.contains("[Budget!D6:D6] D6: new row"), "{text}"); - 264
assert!(text.contains("E1: FALSE"), "{text}"); - 265
assert!(text.contains("A5: =not a formula"), "{text}"); - 266
assert!(part(&applied.bytes, "xl/workbook.xml").contains(r#"<calcPr fullCalcOnLoad="1"/>"#)); - 267
assert_eq!( - 268
untouched(&source, &["xl/workbook.xml", "xl/worksheets/sheet1.xml"]), - 269
untouched( - 270
&applied.bytes, - 271
&["xl/workbook.xml", "xl/worksheets/sheet1.xml"] - 272
), - 273
); - 274
} - 275
- 276
#[test] - 277
fn a_formula_change_removes_the_calculation_chain() { - 278
let source = fixtures::xlsx(); - 279
let types = part(&source, "[Content_Types].xml").replace( - 280
"</Types>", - 281
r#"<Override PartName="/xl/calcChain.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml"/></Types>"#, - 282
); - 283
let rels = part(&source, "xl/_rels/workbook.xml.rels").replace( - 284
"</Relationships>", - 285
r#"<Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" Target="calcChain.xml"/></Relationships>"#, - 286
); - 287
let source = fixtures::with_parts( - 288
&source, - 289
&[ - 290
("[Content_Types].xml", types.as_bytes()), - 291
("xl/_rels/workbook.xml.rels", rels.as_bytes()), - 292
( - 293
"xl/calcChain.xml", - 294
br#"<calcChain xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><c r="B4" i="1"/></calcChain>"#, - 295
), - 296
], - 297
); - 298
let applied = apply( - 299
&source, - 300
vec![OfficeOp::SetCells { - 301
sheet: "Budget".into(), - 302
cells: BTreeMap::from([("B4".to_string(), CellValue::Number(7.0))]), - 303
}], - 304
) - 305
.unwrap(); - 306
let names: Vec<String> = untouched(&applied.bytes, &[]) - 307
.into_iter() - 308
.map(|entry| entry.0) - 309
.collect(); - 310
assert!(!names.contains(&"xl/calcChain.xml".to_string())); - 311
assert!(!part(&applied.bytes, "xl/_rels/workbook.xml.rels").contains("calcChain")); - 312
assert!(!part(&applied.bytes, "[Content_Types].xml").contains("calcChain")); - 313
} - 314
- 315
#[test] - 316
fn appending_rows_and_adding_a_sheet() { - 317
let applied = apply( - 318
&fixtures::xlsx(), - 319
vec![ - 320
OfficeOp::AppendRows { - 321
sheet: "Budget".into(), - 322
rows: vec![ - 323
vec![CellValue::Text("Travel".into()), CellValue::Number(30.0)], - 324
vec![CellValue::Text("Food".into()), CellValue::Number(12.5)], - 325
], - 326
}, - 327
OfficeOp::AddSheet { - 328
name: "Q4 plan".into(), - 329
}, - 330
OfficeOp::SetCells { - 331
sheet: "Q4 plan".into(), - 332
cells: BTreeMap::from([("A1".to_string(), CellValue::Text("Target".into()))]), - 333
}, - 334
], - 335
) - 336
.unwrap(); - 337
let text = lines(&applied); - 338
assert!( - 339
text.contains("[Budget!A5:B5] A5: Travel | B5: 30"), - 340
"{text}" - 341
); - 342
assert!( - 343
text.contains("[Budget!A6:B6] A6: Food | B6: 12.5"), - 344
"{text}" - 345
); - 346
assert!(text.contains("['Q4 plan'!A1:A1] A1: Target"), "{text}"); - 347
let error = apply( - 348
&fixtures::xlsx(), - 349
vec![OfficeOp::AddSheet { - 350
name: "budget".into(), - 351
}], - 352
) - 353
.unwrap_err(); - 354
assert!(error.to_string().contains("already exists"), "{error}"); - 355
} - 356
- 357
#[test] - 358
fn excel_refusals() { - 359
let protected = { - 360
let source = fixtures::xlsx(); - 361
let sheet = part(&source, "xl/worksheets/sheet1.xml").replace( - 362
"</sheetData>", - 363
r#"</sheetData><sheetProtection sheet="1"/>"#, - 364
); - 365
fixtures::with_parts(&source, &[("xl/worksheets/sheet1.xml", sheet.as_bytes())]) - 366
}; - 367
let one = |sheet: &str, cell: &str| OfficeOp::SetCells { - 368
sheet: sheet.into(), - 369
cells: BTreeMap::from([(cell.to_string(), CellValue::Number(1.0))]), - 370
}; - 371
let error = apply(&protected, vec![one("Budget", "A1")]).unwrap_err(); - 372
assert!(error.to_string().contains("is protected"), "{error}"); - 373
let error = apply(&fixtures::xlsx(), vec![one("Nope", "A1")]).unwrap_err(); - 374
assert!( - 375
error.to_string().contains("sheets: Budget, Hidden data"), - 376
"{error}" - 377
); - 378
let error = apply(&fixtures::xlsx(), vec![one("Budget", "ZZZZ1")]).unwrap_err(); - 379
assert!(error.to_string().contains("not a cell address"), "{error}"); - 380
- 381
let shared = { - 382
let source = fixtures::xlsx(); - 383
let sheet = part(&source, "xl/worksheets/sheet1.xml").replace( - 384
"<f>SUM(B2:B3)</f>", - 385
r#"<f t="shared" ref="B4:C4" si="0">SUM(B2:B3)</f>"#, - 386
); - 387
fixtures::with_parts(&source, &[("xl/worksheets/sheet1.xml", sheet.as_bytes())]) - 388
}; - 389
let error = apply(&shared, vec![one("Budget", "B4")]).unwrap_err(); - 390
assert!(error.to_string().contains("shared formula"), "{error}"); - 391
} - 392
- 393
// ---- PowerPoint ---------------------------------------------------------------- - 394
- 395
#[test] - 396
fn a_slide_from_a_template_layout_fills_its_placeholders() { - 397
let source = fixtures::pptx_template(); - 398
let mut placeholders = BTreeMap::new(); - 399
placeholders.insert("title".to_string(), TextValue::One("Agenda".into())); - 400
placeholders.insert( - 401
"body".to_string(), - 402
TextValue::Lines(vec!["Budget".into(), "Hiring".into()]), - 403
); - 404
let applied = apply( - 405
&source, - 406
vec![OfficeOp::AddSlideFromLayout { - 407
layout: "title and content".into(), - 408
after: Some("slide:256".into()), - 409
placeholders, - 410
notes: None, - 411
}], - 412
) - 413
.unwrap(); - 414
let text = lines(&applied); - 415
assert!(text.contains("[slide:257] Slide 2: Agenda"), "{text}"); - 416
assert!(text.contains("Budget ¶ Hiring"), "{text}"); - 417
assert!(part(&applied.bytes, "[Content_Types].xml").contains("/ppt/slides/slide2.xml")); - 418
assert!( - 419
part(&applied.bytes, "ppt/slides/_rels/slide2.xml.rels") - 420
.contains(r#"Target="../slideLayouts/slideLayout2.xml""#) - 421
); - 422
assert_eq!( - 423
untouched( - 424
&source, - 425
&[ - 426
"ppt/presentation.xml", - 427
"ppt/_rels/presentation.xml.rels", - 428
"[Content_Types].xml" - 429
] - 430
), - 431
untouched( - 432
&applied.bytes, - 433
&[ - 434
"ppt/presentation.xml", - 435
"ppt/_rels/presentation.xml.rels", - 436
"[Content_Types].xml", - 437
"ppt/slides/slide2.xml", - 438
"ppt/slides/_rels/slide2.xml.rels" - 439
] - 440
), - 441
); - 442
- 443
let error = apply( - 444
&source, - 445
vec![OfficeOp::AddSlideFromLayout { - 446
layout: "Blank".into(), - 447
after: None, - 448
placeholders: BTreeMap::new(), - 449
notes: None, - 450
}], - 451
) - 452
.unwrap_err(); - 453
assert!( - 454
error.to_string().contains("Title Slide, Title and Content"), - 455
"{error}" - 456
); - 457
let error = apply( - 458
&source, - 459
vec![OfficeOp::AddSlideFromLayout { - 460
layout: "Title Slide".into(), - 461
after: None, - 462
placeholders: BTreeMap::from([("body".to_string(), TextValue::One("x".into()))]), - 463
notes: None, - 464
}], - 465
) - 466
.unwrap_err(); - 467
assert!( - 468
error.to_string().contains("it has: title, subtitle"), - 469
"{error}" - 470
); - 471
} - 472
- 473
#[test] - 474
fn placeholder_text_notes_move_and_delete() { - 475
let applied = apply( - 476
&fixtures::pptx(), - 477
vec![ - 478
OfficeOp::SetPlaceholderText { - 479
anchor: "slide:256/placeholder:title".into(), - 480
text: TextValue::One("Launch plan v2".into()), - 481
}, - 482
OfficeOp::SetPlaceholderText { - 483
anchor: "slide:256/shape:3".into(), - 484
text: TextValue::Lines(vec!["Ship in June".into(), "Beta in May".into()]), - 485
}, - 486
OfficeOp::SetNotes { - 487
anchor: "slide:256".into(), - 488
text: "Mention the new date.".into(), - 489
}, - 490
OfficeOp::MoveSlide { - 491
anchor: "slide:257".into(), - 492
after: None, - 493
}, - 494
], - 495
) - 496
.unwrap(); - 497
let text = lines(&applied); - 498
assert!(text.contains("Slide 2: Launch plan v2"), "{text}"); - 499
assert!( - 500
text.contains("[slide:256/shape:3] Ship in June ¶ Beta in May"), - 501
"{text}" - 502
); - 503
assert!( - 504
text.contains("[slide:256/notes] Mention the new date."), - 505
"{text}" - 506
); - 507
assert!(text.contains("[slide:257] Slide 1: Backup"), "{text}"); - 508
- 509
let deleted = apply( - 510
&fixtures::pptx(), - 511
vec![OfficeOp::DeleteSlide { - 512
anchor: "slide:256".into(), - 513
}], - 514
) - 515
.unwrap(); - 516
let text = lines(&deleted); - 517
assert!(!text.contains("slide:256"), "{text}"); - 518
let names: Vec<String> = untouched(&deleted.bytes, &[]) - 519
.into_iter() - 520
.map(|entry| entry.0) - 521
.collect(); - 522
assert!( - 523
!names - 524
.iter() - 525
.any(|name| name.contains("slide1.xml") || name.contains("notesSlide1")), - 526
"{names:?}" - 527
); - 528
let error = apply( - 529
&deleted.bytes, - 530
vec![OfficeOp::DeleteSlide { - 531
anchor: "slide:257".into(), - 532
}], - 533
) - 534
.unwrap_err(); - 535
assert!(error.to_string().contains("only slide"), "{error}"); - 536
} - 537
- 538
#[test] - 539
fn set_title_and_the_op_schema() { - 540
let applied = apply( - 541
&fixtures::docx(), - 542
vec![OfficeOp::SetTitle { - 543
title: "Q3 Report (final)".into(), - 544
}], - 545
) - 546
.unwrap(); - 547
assert_eq!(applied.document.title.as_deref(), Some("Q3 Report (final)")); - 548
- 549
let parsed: Vec<OfficeOp> = serde_json::from_str( - 550
r#"[{"op":"set_cells","sheet":"Budget","cells":{"B2":150,"C3":"=B3*2","D1":true}}, - 551
{"op":"set_placeholder_text","anchor":"slide:256/placeholder:title","text":["a","b"]}]"#, - 552
) - 553
.unwrap(); - 554
assert_eq!(parsed[0].name(), "set_cells"); - 555
assert!( - 556
serde_json::from_str::<OfficeOp>(r#"{"op":"set_title","title":"x","extra":1}"#).is_err(), - 557
"unknown fields are refused, so a misspelled field is never silently ignored" - 558
); - 559
} - 560
- 561
#[test] - 562
fn a_template_becomes_a_document_but_never_changes_macro_state() { - 563
let template = fixtures::with_parts( - 564
&fixtures::pptx_template(), - 565
&[( - 566
"[Content_Types].xml", - 567
part(&fixtures::pptx_template(), "[Content_Types].xml") - 568
.replace( - 569
"presentationml.presentation.main+xml", - 570
"presentationml.template.main+xml", - 571
) - 572
.as_bytes(), - 573
)], - 574
); - 575
assert_eq!( - 576
Package::open(Cursor::new(template.clone()), Limits::default()) - 577
.unwrap() - 578
.format() - 579
.extension(), - 580
"potx" - 581
); - 582
let pptx = vak_ooxml::Format::from_extension("pptx"); - 583
let applied = edit::apply(&template, &[], &context(), Limits::default(), pptx).unwrap(); - 584
assert_eq!( - 585
Package::open(Cursor::new(applied.bytes), Limits::default()) - 586
.unwrap() - 587
.format() - 588
.extension(), - 589
"pptx" - 590
); - 591
let error = edit::apply( - 592
&fixtures::pptx(), - 593
&[], - 594
&context(), - 595
Limits::default(), - 596
vak_ooxml::Format::from_extension("pptm"), - 597
) - 598
.unwrap_err(); - 599
assert!( - 600
error - 601
.to_string() - 602
.contains("never turns a file macro-enabled"), - 603
"{error}" - 604
); - 605
let error = edit::apply( - 606
&fixtures::pptx(), - 607
&[], - 608
&context(), - 609
Limits::default(), - 610
vak_ooxml::Format::from_extension("docx"), - 611
) - 612
.unwrap_err(); - 613
assert!( - 614
error.to_string().contains("cannot be saved as .docx"), - 615
"{error}" - 616
); - 617
} - 618
- 619
// ---- protection and preservation ------------------------------------------------ - 620
- 621
fn with_word_settings(protection: &str) -> Vec<u8> { - 622
fixtures::word_with( - 623
fixtures::WORD_MAIN, - 624
fixtures::MINIMAL_WORD_BODY, - 625
&[( - 626
"word/settings.xml", - 627
format!( - 628
r#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{protection}</w:settings>"# - 629
) - 630
.as_bytes(), - 631
)], - 632
&[( - 633
"word/settings.xml", - 634
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml", - 635
)], - 636
&[], - 637
&[( - 638
"rId1", - 639
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", - 640
"settings.xml", - 641
)], - 642
) - 643
} - 644
- 645
#[test] - 646
fn protection_is_honoured_before_any_byte_is_written() { - 647
let replace = || OfficeOp::ReplaceParagraphText { - 648
anchor: "p@1".into(), - 649
text: "x".into(), - 650
}; - 651
for (protection, allowed) in [ - 652
( - 653
r#"<w:documentProtection w:edit="readOnly" w:enforcement="1"/>"#, - 654
false, - 655
), - 656
( - 657
r#"<w:documentProtection w:edit="comments" w:enforcement="true"/>"#, - 658
false, - 659
), - 660
( - 661
r#"<w:documentProtection w:edit="trackedChanges" w:enforcement="1"/>"#, - 662
true, - 663
), - 664
( - 665
r#"<w:documentProtection w:edit="readOnly" w:enforcement="0"/>"#, - 666
true, - 667
), - 668
] { - 669
let result = apply(&with_word_settings(protection), vec![replace()]); - 670
assert_eq!(result.is_ok(), allowed, "{protection}: {:?}", result.err()); - 671
if let Err(error) = result { - 672
assert!(error.to_string().contains("is protected"), "{error}"); - 673
} - 674
} - 675
let source = fixtures::pptx(); - 676
let presentation = part(&source, "ppt/presentation.xml").replace( - 677
"<p:sldSz", - 678
r#"<p:modifyVerifier cryptProviderType="rsaAES" cryptAlgorithmClass="hash" cryptAlgorithmType="typeAny" cryptAlgorithmSid="14" spinCount="100000" saltData="AA==" hashData="AA=="/><p:sldSz"#, - 679
); - 680
let locked = fixtures::with_parts( - 681
&source, - 682
&[("ppt/presentation.xml", presentation.as_bytes())], - 683
); - 684
let error = apply( - 685
&locked, - 686
vec![OfficeOp::DeleteSlide { - 687
anchor: "slide:257".into(), - 688
}], - 689
) - 690
.unwrap_err(); - 691
assert!(error.to_string().contains("password to modify"), "{error}"); - 692
} - 693
- 694
#[test] - 695
fn markup_vak_does_not_model_survives_inside_edited_elements() { - 696
let unknown = r#"<x:keep xmlns:x="urn:vak-test" x:note="1"/>"#; - 697
let document = format!( - 698
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr>{unknown}</w:pPr><w:r><w:rPr>{unknown}</w:rPr><w:t>Old</w:t></w:r></w:p><w:p><w:r><w:t>Last</w:t></w:r></w:p></w:body></w:document>"# - 699
); - 700
let word = fixtures::word_with(fixtures::WORD_MAIN, &document, &[], &[], &[], &[]); - 701
for op in [ - 702
OfficeOp::ReplaceParagraphText { - 703
anchor: "p@1".into(), - 704
text: "New".into(), - 705
}, - 706
OfficeOp::DeleteParagraph { - 707
anchor: "p@1".into(), - 708
}, - 709
OfficeOp::AddParagraph { - 710
text: "After".into(), - 711
style: None, - 712
after: Some("p@1".into()), - 713
}, - 714
] { - 715
let name = op.name(); - 716
let applied = apply(&word, vec![op]).unwrap(); - 717
let written = part(&applied.bytes, "word/document.xml"); - 718
assert!( - 719
written.matches(unknown).count() >= 2, - 720
"{name} keeps both originals (a replacement also copies the run's formatting): {written}" - 721
); - 722
} - 723
- 724
let source = fixtures::xlsx(); - 725
let sheet = part(&source, "xl/worksheets/sheet1.xml").replace( - 726
r#"<row r="2">"#, - 727
r#"<row r="2" spans="1:2" x14ac:dyDescent="0.25" xmlns:x14ac="urn:vak-test"><x:ext xmlns:x="urn:vak-test"/>"#, - 728
); - 729
let workbook = fixtures::with_parts(&source, &[("xl/worksheets/sheet1.xml", sheet.as_bytes())]); - 730
let applied = apply( - 731
&workbook, - 732
vec![OfficeOp::SetCells { - 733
sheet: "Budget".into(), - 734
cells: BTreeMap::from([("F2".to_string(), CellValue::Number(9.0))]), - 735
}], - 736
) - 737
.unwrap(); - 738
let written = part(&applied.bytes, "xl/worksheets/sheet1.xml"); - 739
assert!(written.contains(r#"x14ac:dyDescent="0.25""#), "{written}"); - 740
assert!( - 741
written.contains(r#"<x:ext xmlns:x="urn:vak-test"/>"#), - 742
"{written}" - 743
); - 744
assert!( - 745
!written.contains(r#"spans="1:2""#), - 746
"a stale spans hint is dropped: {written}" - 747
); - 748
- 749
let deck_source = fixtures::pptx(); - 750
let slide = part(&deck_source, "ppt/slides/slide1.xml") - 751
.replace("<a:bodyPr/><a:lstStyle/>", "") - 752
.replacen( - 753
"<a:bodyPr/>", - 754
r#"<a:bodyPr wrap="square"><x:keep xmlns:x="urn:vak-test"/></a:bodyPr>"#, - 755
1, - 756
); - 757
let deck = fixtures::with_parts(&deck_source, &[("ppt/slides/slide1.xml", slide.as_bytes())]); - 758
let applied = apply( - 759
&deck, - 760
vec![OfficeOp::SetPlaceholderText { - 761
anchor: "slide:256/placeholder:title".into(), - 762
text: TextValue::One("Kept".into()), - 763
}], - 764
) - 765
.unwrap(); - 766
let written = part(&applied.bytes, "ppt/slides/slide1.xml"); - 767
assert!( - 768
written.contains(r#"<a:bodyPr wrap="square"><x:keep xmlns:x="urn:vak-test"/></a:bodyPr>"#), - 769
"{written}" - 770
); - 771
} - 772
- 773
#[test] - 774
fn several_slide_list_ops_in_one_call_are_checked_against_the_order_they_leave() { - 775
let source = fixtures::pptx_template(); - 776
let slide = |title: &str| OfficeOp::AddSlideFromLayout { - 777
layout: "Title Slide".into(), - 778
after: None, - 779
placeholders: BTreeMap::from([("title".to_string(), TextValue::One(title.into()))]), - 780
notes: None, - 781
}; - 782
let applied = apply( - 783
&source, - 784
vec![ - 785
slide("First"), - 786
slide("Second"), - 787
OfficeOp::MoveSlide { - 788
anchor: "slide:258".into(), - 789
after: None, - 790
}, - 791
OfficeOp::DeleteSlide { - 792
anchor: "slide:256".into(), - 793
}, - 794
], - 795
) - 796
.unwrap(); - 797
let order: Vec<&str> = applied - 798
.document - 799
.sections - 800
.iter() - 801
.map(|section| section.anchor.as_str()) - 802
.collect(); - 803
assert_eq!(order, ["slide:258", "slide:257"]); - 804
assert!( - 805
applied - 806
.results - 807
.iter() - 808
.all(|r| r.check.starts_with("passed")) - 809
); - 810
assert_eq!(applied.results[0].created, vec!["slide:257".to_string()]); - 811
} - 812
- 813
#[test] - 814
fn an_edit_removes_the_signatures_it_invalidates_and_keeps_the_label() { - 815
let source = fixtures::signed_labelled_docx(); - 816
let before = Package::open(Cursor::new(source.clone()), Limits::default()) - 817
.unwrap() - 818
.inspect() - 819
.unwrap(); - 820
assert!(before.signed); - 821
let applied = apply( - 822
&source, - 823
vec![OfficeOp::ReplaceParagraphText { - 824
anchor: "p@1".into(), - 825
text: "Hello again".into(), - 826
}], - 827
) - 828
.unwrap(); - 829
assert_eq!(applied.notices.len(), 1, "{:?}", applied.notices); - 830
assert!(applied.notices[0].contains("1 signature(s) were removed")); - 831
let after = &applied.document.inspection; - 832
assert!(!after.signed, "the output claims no signature"); - 833
assert_eq!( - 834
after.sensitivity_labels, - 835
["Confidential"], - 836
"the label stays" - 837
); - 838
let archive = zip::ZipArchive::new(Cursor::new(applied.bytes.clone())).unwrap(); - 839
assert!( - 840
!archive - 841
.file_names() - 842
.any(|name| name.starts_with("_xmlsignatures/")), - 843
"{:?}", - 844
archive.file_names().collect::<Vec<_>>() - 845
); - 846
let types = part(&applied.bytes, "[Content_Types].xml"); - 847
assert!(!types.contains("xmlsignatures"), "{types}"); - 848
assert!(!part(&applied.bytes, "_rels/.rels").contains("digital-signature")); - 849
assert_eq!( - 850
part(&applied.bytes, "docProps/custom.xml"), - 851
fixtures::CONFIDENTIAL_CUSTOM_PROPERTIES, - 852
"the label's part is copied as it was" - 853
); - 854
- 855
let unsigned = apply( - 856
&fixtures::docx(), - 857
vec![OfficeOp::ReplaceParagraphText { - 858
anchor: "p@11".into(), - 859
text: "Growing.".into(), - 860
}], - 861
) - 862
.unwrap(); - 863
assert!(unsigned.notices.is_empty()); - 864
} - 865
- 866
#[test] - 867
fn a_paragraph_named_by_its_text_gets_its_anchor_in_the_error() { - 868
let error = apply( - 869
&fixtures::docx(), - 870
vec![OfficeOp::ReplaceParagraphText { - 871
anchor: "p:Steady.".into(), - 872
text: "Growing fast.".into(), - 873
}], - 874
) - 875
.unwrap_err() - 876
.to_string(); - 877
assert!( - 878
error.contains("The paragraph whose text is \"Steady.\" is p@11"), - 879
"{error}" - 880
); - 881
let error = apply( - 882
&fixtures::docx(), - 883
vec![OfficeOp::DeleteParagraph { - 884
anchor: "p:nothing like this".into(), - 885
}], - 886
) - 887
.unwrap_err() - 888
.to_string(); - 889
assert!( - 890
error.ends_with("(anchors look like p:1A2B3C4D or p@12)"), - 891
"{error}" - 892
); - 893
} - 894
- 895
// ---- Word redlines: only what changed ---------------------------------------------- - 896
- 897
const WORD_NAMESPACES: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; - 898
- 899
/// A Word document whose body is `paragraphs` and a closing paragraph. - 900
fn word_document(paragraphs: &str) -> Vec<u8> { - 901
let document = format!( - 902
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {WORD_NAMESPACES}><w:body>{paragraphs}<w:p><w:r><w:t>End.</w:t></w:r></w:p><w:sectPr/></w:body></w:document>"# - 903
); - 904
fixtures::word_with(fixtures::WORD_MAIN, &document, &[], &[], &[], &[]) - 905
} - 906
- 907
const BOLD_LEAD: &str = r#"<w:r><w:rPr><w:b/></w:rPr><w:t>Term.</w:t></w:r>"#; - 908
const DEFINED_TERM: &str = r#"<w:r><w:rPr><w:b/></w:rPr><w:t>Effective Date</w:t></w:r>"#; - 909
const LINK: &str = r#"<w:hyperlink w:anchor="schedule"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>the schedule</w:t></w:r></w:hyperlink>"#; - 910
const FOOTNOTE_MARK: &str = r#"<w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:footnoteReference w:id="1"/></w:r>"#; - 911
const CLAUSE: &str = "Term. This Agreement begins on the Effective Date and continues for twelve months, see the schedule."; - 912
- 913
/// A clause as contracts are written: a bold lead-in, a bold defined term, - 914
/// a link and a footnote mark. - 915
fn clause() -> Vec<u8> { - 916
word_document(&format!( - 917
r#"<w:p>{BOLD_LEAD}<w:r><w:t xml:space="preserve"> This Agreement begins on the </w:t></w:r>{DEFINED_TERM}<w:r><w:t xml:space="preserve"> and continues for twelve months, see </w:t></w:r>{LINK}<w:r><w:t>.</w:t></w:r>{FOOTNOTE_MARK}</w:p>"# - 918
)) - 919
} - 920
- 921
fn replace(bytes: &[u8], anchor: &str, text: &str) -> Result<edit::Applied, edit::EditError> { - 922
apply( - 923
bytes, - 924
vec![OfficeOp::ReplaceParagraphText { - 925
anchor: anchor.into(), - 926
text: text.into(), - 927
}], - 928
) - 929
} - 930
- 931
/// The first paragraph of the body, as written. - 932
fn first_paragraph(bytes: &[u8]) -> String { - 933
let document = part(bytes, "word/document.xml"); - 934
let start = document.find("<w:body>").unwrap() + "<w:body>".len(); - 935
let end = start + document[start..].find("</w:p>").unwrap() + "</w:p>".len(); - 936
document[start..end].to_string() - 937
} - 938
- 939
#[test] - 940
fn a_changed_word_is_the_only_change_and_everything_around_it_is_kept() { - 941
let applied = replace(&clause(), "p@1", &CLAUSE.replace("twelve", "twenty-four")).unwrap(); - 942
assert!( - 943
lines(&applied).contains( - 944
"[p@1] Term. This Agreement begins on the Effective Date and continues for [deleted by Mira: twelve][inserted by Mira: twenty-four] months, see the schedule." - 945
), - 946
"{}", - 947
lines(&applied) - 948
); - 949
let paragraph = first_paragraph(&applied.bytes); - 950
for kept in [BOLD_LEAD, DEFINED_TERM, LINK, FOOTNOTE_MARK] { - 951
assert!( - 952
paragraph.contains(kept), - 953
"{kept} is copied byte for byte: {paragraph}" - 954
); - 955
} - 956
assert_eq!(paragraph.matches("<w:del ").count(), 1, "{paragraph}"); - 957
assert_eq!(paragraph.matches("<w:ins ").count(), 1, "{paragraph}"); - 958
assert!( - 959
paragraph.contains(r#"<w:r><w:t xml:space="preserve">twenty-four</w:t></w:r></w:ins>"#), - 960
"the new word looks like the plain word it replaces, not the bold lead-in: {paragraph}" - 961
); - 962
let result = &applied.results[0]; - 963
assert!( - 964
result.summary.contains(r#""twelve" → "twenty-four""#), - 965
"{}", - 966
result.summary - 967
); - 968
assert!(result.check.starts_with("passed")); - 969
} - 970
- 971
#[test] - 972
fn replaced_text_looks_like_the_text_it_replaces() { - 973
let applied = replace( - 974
&clause(), - 975
"p@1", - 976
&CLAUSE.replace("Effective Date", "Commencement Date"), - 977
) - 978
.unwrap(); - 979
let paragraph = first_paragraph(&applied.bytes); - 980
assert!( - 981
paragraph.contains( - 982
r#"<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">Commencement</w:t></w:r></w:ins>"# - 983
), - 984
"a replaced bold word stays bold: {paragraph}" - 985
); - 986
- 987
let applied = replace( - 988
&clause(), - 989
"p@1", - 990
&CLAUSE.replace("the schedule", "the annex"), - 991
) - 992
.unwrap(); - 993
let paragraph = first_paragraph(&applied.bytes); - 994
let link = ¶graph - 995
[paragraph.find("<w:hyperlink").unwrap()..paragraph.find("</w:hyperlink>").unwrap()]; - 996
assert!( - 997
link.contains( - 998
r#"<w:rStyle w:val="Hyperlink"/></w:rPr><w:t xml:space="preserve">annex</w:t>"# - 999
) && link.contains(r#"<w:delText xml:space="preserve">schedule</w:delText>"#), - 1000
"a link's text replaced inside the link is still the link: {link}"
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.