use std::path::{Path, PathBuf};
use twip::docx::props::Jc;
use twip::editing::commands::{Command, Dir, MoveBy};
use twip::editing::format::Toggle;
use twip::editing::position::{Position, StoryId};
use twip::editing::selection::{ObjectRef, Selection};
use twip::editing::{Editor, inspect};
use twip::font::Fonts;
use twip::layout::Unit;
fn files(paths: &[String]) -> Vec<PathBuf> {
fn walk(p: &Path, out: &mut Vec<PathBuf>) {
if p.is_dir() {
let mut v: Vec<PathBuf> = std::fs::read_dir(p).into_iter().flatten().flatten().map(|e| e.path()).collect();
v.sort();
v.iter().for_each(|q| walk(q, out));
} else if p.extension().is_some_and(|x| x == "docx") && !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("~$")) {
out.push(p.to_path_buf());
}
}
let mut out = Vec::new();
paths.iter().for_each(|p| walk(Path::new(p), &mut out));
out
}
fn families() -> Vec<(&'static str, Vec<Command>)> {
vec![
("typing", vec![Command::InsertText("Typed text. ".into())]),
(
"enter",
vec![Command::Move { by: MoveBy::Word, dir: Dir::Forward, extend: false }, Command::Enter, Command::InsertText("New paragraph".into())],
),
("delete", vec![Command::Move { by: MoveBy::Word, dir: Dir::Forward, extend: true }, Command::Delete { dir: Dir::Back, word: false }]),
("join", vec![Command::Move { by: MoveBy::Para, dir: Dir::Forward, extend: false }, Command::Delete { dir: Dir::Back, word: false }]),
("breaks", vec![Command::LineBreak, Command::InsertTab, Command::PageBreak]),
("select-all-typing", vec![Command::SelectAll, Command::InsertText("Replaced".into())]),
(
"bold",
vec![Command::Move { by: MoveBy::Word, dir: Dir::Forward, extend: true }, Command::Toggle(Toggle::Bold), Command::InsertText("x".into())],
),
("italic-all", vec![Command::SelectAll, Command::Toggle(Toggle::Italic)]),
("align", vec![Command::Align(Jc::Center), Command::Move { by: MoveBy::Para, dir: Dir::Forward, extend: true }, Command::Align(Jc::Right)]),
]
}
fn in_table(e: &mut Editor, fonts: &Fonts) -> bool {
for _ in 0..400 {
if e.selection().focus().is_some_and(|p| !e.index().chain_of(&p).is_empty()) {
return true;
}
e.execute(Command::Move { by: MoveBy::Para, dir: Dir::Forward, extend: false }, fonts);
}
false
}
fn table_family(e: &mut Editor, fonts: &Fonts) -> bool {
for _ in 0..400 {
if e.selection().focus().is_some_and(|p| !e.index().chain_of(&p).is_empty()) {
break;
}
e.execute(Command::Move { by: MoveBy::Para, dir: Dir::Forward, extend: false }, fonts);
}
if !e.selection().focus().is_some_and(|p| !e.index().chain_of(&p).is_empty()) {
return false;
}
for _ in 0..2000 {
let before = e.history().undo_depth();
e.execute(Command::NextCell { back: false }, fonts);
if e.history().undo_depth() > before {
e.execute(Command::InsertText("row".into()), fonts);
return true;
}
}
false
}
fn story_starts(e: &Editor) -> Vec<Position> {
use twip::docx::{Block, Inline};
use twip::layout::{FloatId, ParaId};
let first = |b: &[Block]| b.iter().find_map(|b| if let Block::Para(p) = b { Some(ParaId(p.id)) } else { None });
let d = e.document();
let mut out = Vec::new();
for (name, b) in &d.stories {
if let (Some(s), Some(p)) = (e.index().header_footer(name), first(b)) {
out.push(Position::text(s, p, Unit(0)));
}
}
for (id, b) in &d.footnotes {
out.extend(first(b).map(|p| Position::text(StoryId::Footnote(*id), p, Unit(0))));
}
for (id, b) in &d.endnotes {
out.extend(first(b).map(|p| Position::text(StoryId::Endnote(*id), p, Unit(0))));
}
for b in d.sections.iter().flat_map(|s| s.blocks.iter()) {
if let Block::Para(p) = b {
for i in &p.inlines {
if let Inline::Float(f, _) = i
&& let Some(tb) = &f.text_box
{
out.extend(first(&tb.blocks).map(|q| Position::text(StoryId::TextBox(FloatId(f.id)), q, Unit(0))));
}
}
}
}
out
}
fn floats(e: &Editor) -> Vec<twip::layout::FloatId> {
use twip::docx::{Block, Inline};
e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let Inline::Float(f, _) = i { Some(twip::layout::FloatId(f.id)) } else { None })
.collect()
}
fn dump(e: &mut Editor) -> serde_json::Value {
inspect::inspect(e, "dump", true)["dump"].clone()
}
fn send(e: &mut Editor, fonts: &Fonts, mut msg: serde_json::Value, pending: &mut bool) -> serde_json::Value {
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
msg["seq"] = serde_json::json!(SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let r: serde_json::Value = serde_json::from_str(&e.apply_json(&msg.to_string(), fonts)).unwrap_or_default();
if std::env::var_os("RT_TRACE").is_some() {
eprintln!(" {msg} -> {}", r.get("error").or(r.get("answer")).map(|v| v.to_string()).unwrap_or_default());
}
*pending |= r["answer"]["error"] == "unknown command";
r
}
fn first_element(e: &mut Editor, fonts: &Fonts, field: bool) -> Option<serde_json::Value> {
let r = send(e, fonts, serde_json::json!({ "type": "query", "q": "elements" }), &mut false);
r["answer"].as_array()?.iter().find(|x| (x["kind"] == "field") == field).map(|x| x["id"].clone())
}
const MERGE: &[&str] = &["field-insert", "field-code", "field-remove", "control-insert", "control-tag", "region-wrap", "data-write", "data-remove"];
fn merge_family(e: &mut Editor, fonts: &Fonts, label: &str, pending: &mut bool) -> bool {
use serde_json::json;
let command = |id: &str, arg: serde_json::Value| json!({ "type": "command", "id": id, "arg": arg });
let before = e.history().undo_depth();
let msg = match label {
"field-insert" => command("field.insert", json!({ "code": "MERGEFIELD Roundtrip.Name" })),
"field-code" => match first_element(e, fonts, true) {
Some(id) => command("field.set", json!({ "id": id, "code": "MERGEFIELD Roundtrip.Changed" })),
None => return false,
},
"field-remove" => match first_element(e, fonts, true) {
Some(id) => command("element.remove", json!({ "id": id })),
None => return false,
},
"control-insert" => command("control.insert", json!({ "kind": "plain_text", "tag": "Roundtrip" })),
"control-tag" => match first_element(e, fonts, false) {
Some(id) => command("control.set", json!({ "id": id, "tag": "Roundtrip:changed" })),
None => return false,
},
"region-wrap" => {
if !in_table(e, fonts) {
return false;
}
e.execute(Command::SelectCells(twip::editing::selection::CellShape::Rows), fonts);
command("region.wrap", json!({ "tag": "Roundtrip:rows" }))
}
"data-write" => {
command("data.write", json!({ "namespace": "urn:roundtrip", "xml": "<roundtrip xmlns=\"urn:roundtrip\"><a>1</a></roundtrip>" }))
}
"data-remove" => {
let r = send(e, fonts, json!({ "type": "query", "q": "data" }), &mut false);
match r["answer"].as_array().and_then(|v| v.first()).map(|p| p["namespace"].clone()) {
Some(ns) => command("data.remove", json!({ "namespace": ns })),
None => return false,
}
}
_ => return false,
};
send(e, fonts, msg, pending);
e.history().undo_depth() > before
}
fn merge_state(e: &mut Editor, fonts: &Fonts) -> serde_json::Value {
let mut strip = |q: &str| {
let mut v = send(e, fonts, serde_json::json!({ "type": "query", "q": q }), &mut false)["answer"].take();
for x in v.as_array_mut().into_iter().flatten() {
if let Some(o) = x.as_object_mut() {
o.retain(|k, _| !["id", "parent"].contains(&k.as_str()));
if let Some(s) = o.get_mut("story").filter(|s| s.as_str().is_some_and(|s| s.starts_with("text box "))) {
*s = "text box".into();
}
}
}
v
};
serde_json::json!({ "elements": strip("elements"), "data": strip("data") })
}
fn merge_difference(a: &serde_json::Value, b: &serde_json::Value) -> String {
for k in ["elements", "data"] {
let (x, y) = (a[k].as_array().cloned().unwrap_or_default(), b[k].as_array().cloned().unwrap_or_default());
if let Some(i) = (0..x.len().max(y.len())).find(|&i| x.get(i) != y.get(i)) {
let at = |v: &[serde_json::Value]| v.get(i).map_or("nothing".into(), |e| e.to_string().chars().take(400).collect::<String>());
return format!("{k}[{i}] (of {} and {}): {} became {}", x.len(), y.len(), at(&x), at(&y));
}
}
String::new()
}
pub fn run(args: &[String]) -> Result<(), String> {
let edits = args.iter().position(|a| a == "--edits").map(|i| args.get(i + 1).cloned().unwrap_or_default());
let keep = args.iter().position(|a| a == "--keep").and_then(|i| args.get(i + 1)).map(PathBuf::from);
let skip: Vec<usize> = args.iter().enumerate().filter(|(_, a)| *a == "--edits" || *a == "--keep").flat_map(|(i, _)| [i, i + 1]).collect();
let paths: Vec<String> = args.iter().enumerate().filter(|(i, _)| !skip.contains(i)).map(|(_, a)| a.clone()).collect();
if let Some(k) = &keep {
std::fs::create_dir_all(k).map_err(|e| format!("{}: {e}", k.display()))?;
}
let fonts = Fonts::system();
let (mut ok, mut failed, mut skipped) = (0, Vec::new(), 0);
let mut pending: Vec<&str> = Vec::new();
for f in files(&paths) {
let bytes = std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display()))?;
let name = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
if edits.is_none() {
match twip::docx::read_editable(&bytes).and_then(|d| twip::docx::write::save(&d)) {
Ok(out) if out == bytes => ok += 1,
Ok(_) => failed.push(format!("{}: unedited save changed the bytes", f.display())),
Err(e) => failed.push(format!("{}: {e}", f.display())),
}
continue;
}
let Ok(probe) = Editor::open(&bytes, &fonts) else {
skipped += 1;
continue;
};
drop(probe);
let mut run_family = |label: &str, apply: &mut dyn FnMut(&mut Editor) -> bool| {
if std::env::var_os("RT_TRACE").is_some() {
eprintln!("{} {label}", f.display());
}
let Ok(mut e) = Editor::open(&bytes, &fonts) else { return };
if !apply(&mut e) {
return;
}
let want = dump(&mut e);
let want_merge = (edits.as_deref() == Some("merge")).then(|| merge_state(&mut e, &fonts));
let saved = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| twip::docx::write::save(e.document()))).unwrap_or_else(|p| {
Err(format!(
"the writer panicked: {}",
p.downcast_ref::<String>().cloned().or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_default()
))
});
match saved {
Ok(out) => {
if let Some(k) = &keep {
let _ = std::fs::write(k.join(format!("{name}.{label}.docx")), &out);
}
match Editor::open(&out, &fonts).map(|mut again| (dump(&mut again), want_merge.as_ref().map(|_| merge_state(&mut again, &fonts))))
{
Ok((got, merge)) if got == want && merge == want_merge => ok += 1,
Ok((got, merge)) if got == want => failed.push(format!(
"{} {label}: the saved elements or data parts read back differently: {}",
f.display(),
merge_difference(
want_merge.as_ref().unwrap_or(&serde_json::Value::Null),
merge.as_ref().unwrap_or(&serde_json::Value::Null)
)
)),
Ok((got, _)) => {
let (a, b) = (
want["paragraphs"].as_array().cloned().unwrap_or_default(),
got["paragraphs"].as_array().cloned().unwrap_or_default(),
);
let at = (0..a.len().max(b.len())).find(|&i| a.get(i) != b.get(i));
let (sa, sb) = (
at.and_then(|i| a.get(i)).map(|p| p.to_string()).unwrap_or_default(),
at.and_then(|i| b.get(i)).map(|p| p.to_string()).unwrap_or_default(),
);
let from = sa.chars().zip(sb.chars()).take_while(|(x, y)| x == y).count().saturating_sub(40);
let show = |v: &[serde_json::Value], i: Option<usize>| {
i.and_then(|i| v.get(i)).map_or("-".to_string(), |p| p.to_string().chars().skip(from).take(160).collect())
};
let other = || {
let keys = want.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default();
let k = keys.iter().find(|k| *k != "paragraphs" && want[k.as_str()] != got[k.as_str()])?;
let (x, y) = (want[k.as_str()].to_string(), got[k.as_str()].to_string());
let from = x.chars().zip(y.chars()).take_while(|(p, q)| p == q).count().saturating_sub(60);
let cut = |s: &str| s.chars().skip(from).take(240).collect::<String>();
Some(format!("{k}: {} became {}", cut(&x), cut(&y)))
};
let what = match at {
Some(_) => {
format!("paragraph {at:?} {} became {} ({} → {} paragraphs)", show(&a, at), show(&b, at), a.len(), b.len())
}
None => other().unwrap_or_default(),
};
failed.push(format!("{} {label}: the saved document reads back differently: {what}", f.display()))
}
Err(err) => failed.push(format!("{} {label}: the saved document doesn't read: {err}", f.display())),
}
}
Err(err) => failed.push(format!("{} {label}: {err}", f.display())),
}
};
if edits.as_deref() == Some("merge") {
for label in MERGE {
let mut not_yet = false;
run_family(label, &mut |e| merge_family(e, &fonts, label, &mut not_yet));
if not_yet && !pending.contains(label) {
pending.push(label);
}
}
continue;
}
for (label, cmds) in families() {
run_family(label, &mut |e| {
for c in &cmds {
e.execute(c.clone(), &fonts);
}
e.history().undo_depth() > 0
});
}
run_family("table-row", &mut |e| table_family(e, &fonts));
for (label, cmd) in [
("insert-column", Command::InsertColumns { right: true }),
("delete-row", Command::DeleteRows),
("split-cell", Command::SplitCell { cols: 2 }),
("nested-table", Command::InsertTable { rows: 2, cols: 2 }),
] {
run_family(label, &mut |e| {
in_table(e, &fonts) && {
let before = e.history().undo_depth();
e.execute(cmd.clone(), &fonts);
e.execute(Command::InsertText("t".into()), &fonts);
e.history().undo_depth() > before
}
});
}
run_family("stories", &mut |e| {
for p in story_starts(e) {
e.set_caret_for_benchmark(Selection::Caret(p));
e.execute(Command::InsertText("Story ".into()), &fonts);
}
e.history().undo_depth() > 0
});
for (label, del) in [("float-nudge", false), ("float-delete", true)] {
run_family(label, &mut |e| {
let Some(&f) = floats(e).first() else { return false };
e.set_caret_for_benchmark(Selection::Object(ObjectRef::Floating(f)));
let c = if del {
Command::Delete { dir: Dir::Forward, word: false }
} else {
Command::Move { by: MoveBy::Char, dir: Dir::Forward, extend: false }
};
e.execute(c, &fonts);
e.history().undo_depth() > 0
});
}
run_family("new-table", &mut |e| {
e.execute(Command::InsertTable { rows: 2, cols: 3 }, &fonts);
e.execute(Command::InsertText("cell".into()), &fonts);
e.history().undo_depth() > 0
});
}
for f in &failed {
println!("FAIL {f}");
}
if !pending.is_empty() {
println!("pending (command not implemented yet): {}", pending.join(", "));
}
println!("{ok} ok, {} failed, {skipped} not readable", failed.len());
if failed.is_empty() { Ok(()) } else { Err(format!("{} round trips failed", failed.len())) }
}