Files
wordcraft/crates/engine/src/cmd/tools/compare.rs
T

450 lines
20 KiB
Rust

use super::{
Arc, Block, CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, RevisionKind, Selection, Session, StoryRef, Value, json, p,
para_block,
};
use wordcraft_doc::para::OBJ;
/// Compare and Combine take the same settings.
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
/// What Compare / Combine compares (the More >> settings of Word's dialog).
struct CompareOptions {
/// Differences only in letter case count as changes.
case: bool,
/// Differences only in white space count as changes.
white_space: bool,
/// Formatting differences become tracked formatting changes.
formatting: bool,
/// Changed paragraphs are compared character by character (else word by word).
characters: bool,
}
impl CompareOptions {
/// The text a comparison sees: case folded and white space collapsed as the options say.
fn key(&self, t: &str) -> String {
let t = if self.white_space { t.to_string() } else { t.split_whitespace().collect::<Vec<_>>().join(" ") };
if self.case { t } else { t.to_lowercase() }
}
}
/// Changed paragraphs longer than this (in characters) are compared word by word even at
/// character level, so the comparison table stays small.
const MAX_CHAR_DIFF: usize = 2000;
/// Compare the current document (original, or `original` from a file) with a revised one: the
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
let revised = if let Some(t) = p::str(v, "text") {
Document::from_text(t)
} else if let Some(path) = p::str(v, "path") {
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
} else {
return Err(CmdError::Params("`path` or `text` of the revised document is required".into()));
};
let show_in = p::str(v, "showIn").unwrap_or("original");
if !matches!(show_in, "original" | "revised" | "new") {
return Err(CmdError::Params("`showIn` is \"original\", \"revised\" or \"new\"".into()));
}
if let Some(path) = p::str(v, "original") {
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
s.doc = doc;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
}
let level = p::str(v, "level").unwrap_or("word");
let opts = CompareOptions {
case: p::bool(v, "caseChanges").unwrap_or(true),
white_space: p::bool(v, "whiteSpace").unwrap_or(true),
formatting: p::bool(v, "formatting").unwrap_or(true),
characters: matches!(level, "character" | "char"),
};
let not_honoured: Vec<&str> =
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
let date = crate::cmd::now_iso();
let old: Vec<Arc<Block>> = s.doc.body.clone();
let new: Vec<Arc<Block>> = if revised.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::TextBox) {
// The revised document's text boxes get stories of their own in the result, so its
// boxes never show a story of the original's (or none at all).
s.doc.adopt_fragment(&revised.body_fragment()).into_iter().map(Arc::new).collect()
} else {
revised.body.clone()
};
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone(), move_name: None });
let ins = (s.doc.revisions.len() - 1) as u32;
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author: author.clone(), date: date.clone(), move_name: None });
let del = (s.doc.revisions.len() - 1) as u32;
let key = |b: &Arc<Block>| match &**b {
Block::Para(p) => opts.key(&p.plain_text()),
// Tables match as a whole, by their text.
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let mut budget = MAX_COMPARE_CELLS;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
let mut k = 0;
while k < ops.len() {
match ops.get(k) {
Some(Op::Same) => {
let (a, b) = (old.get(i), new.get(j));
// The revised block, with its formatting changes tracked.
if let (Some(Block::Para(pa)), Some(Block::Para(pb))) = (a.map(|x| &**x), b.map(|x| &**x)) {
if !same_objects(&s.doc, pa, pb) {
// Same text, but objects (text boxes, pictures…) differ: diffed, so the
// original's objects aren't lost.
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)));
changes += 1;
i += 1;
j += 1;
k += 1;
continue;
}
let mut p = pb.clone();
if opts.formatting && pa.text == pb.text {
let before = s.doc.revisions.len();
crate::cmd::fmt_revisions::compare_formatting(pa, &mut p, &mut s.doc.revisions, &author, &date);
if s.doc.revisions.len() > before || p != *pb {
changes += 1;
}
}
out.push(para_block(p));
} else if let Some(x) = b.or(a) {
out.push(x.clone());
}
i += 1;
j += 1;
k += 1;
}
Some(_) => {
// A run of deletions and insertions: changed paragraphs pair up and are diffed
// word by word (or by character); the rest are deleted or inserted whole.
let run = ops.get(k..).unwrap_or_default().iter().take_while(|o| **o != Op::Same).count();
let dels = ops.get(k..k + run).unwrap_or_default().iter().filter(|o| **o == Op::Del).count();
let inss = run - dels;
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => {
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)))
}
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
}
if let Some(x) = b {
out.push(Arc::new(mark_block(x, &|c| c.ins = Some(ins), 0)));
}
}
}
changes += 1;
}
i += dels;
j += inss;
k += run.max(1);
}
None => break,
}
}
s.doc.body = out;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
if show_in != "original" || p::str(v, "original").is_some() {
// The result is a new, untitled document: saving it never overwrites either file.
s.path = None;
s.file_name = None;
// Version snapshots belong to the document they were taken from, including when
// Compare creates an untitled result from the current document.
s.clear_versions();
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
}
/// A block's text (a table's cells in order), for matching blocks.
fn block_text(b: &Block, depth: usize) -> String {
match b {
Block::Para(p) => p.plain_text(),
Block::Table(t) if depth < 16 => {
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|x| block_text(x, depth + 1)).collect::<Vec<_>>().join("\t")
}
Block::Table(_) => String::new(),
}
}
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
match b {
Block::Para(p) => {
let mut p = p.clone();
let len = p.len();
let _ = p.format(0, len, f);
Block::Para(p)
}
Block::Table(t) => {
let mut t = t.clone();
if depth < 16 {
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
c.blocks = c.blocks.iter().map(|x| Arc::new(mark_block(x, f, depth + 1))).collect();
}
}
Block::Table(t)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Op {
Same,
Del,
Ins,
}
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
/// compared as deleted and inserted, never dropped.
const MAX_LCS: usize = 4000;
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
const MAX_COMPARE_CELLS: u64 = 50_000_000;
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
pub(super) fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut ops = vec![Op::Same; pre];
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget {
(n, m) = (0, 0);
} else {
*budget -= cells;
}
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
let v = if a.get(i) == b.get(j) {
t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1
} else {
t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0))
};
if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) {
*c = v;
}
}
}
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if a.get(i) == b.get(j) {
ops.push(Op::Same);
i += 1;
j += 1;
} else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) {
ops.push(Op::Del);
i += 1;
} else {
ops.push(Op::Ins);
j += 1;
}
}
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
ops.extend(std::iter::repeat_n(Op::Same, suf));
ops
}
/// How short an interior match can be, inside a run of edits, before it's folded into the
/// surrounding delete/insert instead of staying its own little island of "same" text. Two short
/// strings that happen to share a character in a different order (common with digits in dates,
/// IDs, phone numbers) otherwise come out of `lcs_ops` as a true shortest edit script, but render
/// as a "ransom note" of alternating single-character deletions and insertions where a reader
/// (and Word) would expect one clean replacement.
const NOISE_MATCH_LEN: usize = 1;
/// Folds single-token matches that sit strictly inside a run of edits into the surrounding
/// delete/insert, so a character-level diff doesn't fragment a replaced run. A matched run that
/// opens or closes the whole sequence is the genuine common prefix/suffix `lcs_ops` already
/// stripped before running its table, so it's left untouched regardless of length; only a short
/// match produced by the table's own backtracking, which can only ever be interior, is a
/// candidate.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Keep,
Fold,
}
pub(super) fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
let mut runs: Vec<(Op, usize, Kind)> = Vec::new();
let mut k = 0;
while k < ops.len() {
let op = ops[k];
let len = ops[k..].iter().take_while(|o| **o == op).count();
let kind = if op == Op::Same && k != 0 && k + len != ops.len() && len <= NOISE_MATCH_LEN { Kind::Fold } else { Kind::Keep };
runs.push((op, len, kind));
k += len;
}
if !runs.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
return ops.to_vec();
}
let mut out = Vec::with_capacity(ops.len());
let mut i = 0;
while i < runs.len() {
if runs[i].0 == Op::Same && runs[i].2 == Kind::Keep {
out.extend(std::iter::repeat_n(Op::Same, runs[i].1));
i += 1;
continue;
}
let start = i;
while i < runs.len() && !(runs[i].0 == Op::Same && runs[i].2 == Kind::Keep) {
i += 1;
}
let region = &runs[start..i];
if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
flatten_region(&mut out, region);
} else {
for (op, len, _) in region {
out.extend(std::iter::repeat_n(*op, *len));
}
}
}
out
}
/// Flattens a region with a folded run: every original character first (in its own order), then
/// every revised character (in its own order) — one clean delete, one clean insert.
fn flatten_region(out: &mut Vec<Op>, region: &[(Op, usize, Kind)]) {
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
out.extend(std::iter::repeat_n(Op::Del, dels));
out.extend(std::iter::repeat_n(Op::Ins, inss));
}
/// A token of a changed paragraph: a word (or character), or an inline object without text
/// of its own (a text box anchor, a picture, a bookmark…), which is compared and kept whole.
enum Tok<'a> {
Text(String),
Obj(&'a InlineObject),
}
/// What a comparison sees of a token: the text as the options say, an object with the text of
/// the text boxes it shows instead of their story ids (each document numbers its own stories).
#[derive(PartialEq)]
enum Key {
Text(String),
Obj(Box<InlineObject>, Vec<String>),
}
fn obj_key(doc: &Document, o: &InlineObject) -> Key {
let texts = o.text_boxes().into_iter().map(|id| doc.plain_text(StoryRef::Part(id))).collect();
let mut o = o.clone();
for story in o.text_box_slots() {
*story = None;
}
Key::Obj(Box::new(o), texts)
}
/// Whether two paragraphs hold the same objects, as compared.
fn same_objects(doc: &Document, a: &Paragraph, b: &Paragraph) -> bool {
a.objects.len() == b.objects.len() && a.objects.iter().zip(&b.objects).all(|(x, y)| x == y || obj_key(doc, x) == obj_key(doc, y))
}
/// Words with the spaces after them ("The ", "cat "), or single characters, and the objects
/// between them. Objects with text (fields, equations…) count as their text.
fn tokens(p: &Paragraph, characters: bool) -> Vec<Tok<'_>> {
fn push(v: &mut Vec<Tok<'_>>, cur: &mut String, c: char, characters: bool) {
cur.push(c);
if characters || c == ' ' {
v.push(Tok::Text(std::mem::take(cur)));
}
}
let mut v = Vec::new();
let mut cur = String::new();
let mut objects = p.objects.iter();
for c in p.text.chars() {
if c != OBJ {
push(&mut v, &mut cur, c, characters);
continue;
}
match objects.next() {
Some(o) if o.plain_text().is_empty() => {
if !cur.is_empty() {
v.push(Tok::Text(std::mem::take(&mut cur)));
}
v.push(Tok::Obj(o));
}
Some(o) => o.plain_text().chars().for_each(|c| push(&mut v, &mut cur, c, characters)),
None => {
// A marker without its object (a broken paragraph): kept as a visible
// placeholder, so the loss shows in the result instead of vanishing.
log::warn!("compare: an object marker without its object");
push(&mut v, &mut cur, char::REPLACEMENT_CHARACTER, characters);
}
}
}
if !cur.is_empty() {
v.push(Tok::Text(cur));
}
v
}
/// Add a token at the end of `p` with `props`.
fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) {
let at = p.len();
match t {
Some(Tok::Text(t)) => {
let _ = p.insert_text(at, t, props);
}
Some(Tok::Obj(o)) => {
let _ = p.insert_object(at, (*o).clone(), props);
}
None => {}
}
}
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
let (ta, tb) = (tokens(a, chars), tokens(b, chars));
let key = |t: &Tok<'_>| match t {
Tok::Text(t) => Key::Text(opts.key(t)),
Tok::Obj(o) => obj_key(doc, o),
};
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), budget);
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
let ops = if chars { collapse_noise_matches(&ops) } else { ops };
let mut p = Paragraph::new();
p.props = b.props.clone();
let base = a.runs.first().map(|r| r.props.clone()).unwrap_or_default();
let (mut i, mut j) = (0, 0);
for op in ops {
match op {
Op::Same => {
// Equal as compared (perhaps up to case or spacing): the revised text.
put(&mut p, tb.get(j), &base);
i += 1;
j += 1;
}
Op::Del => {
put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() });
i += 1;
}
Op::Ins => {
put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() });
j += 1;
}
}
}
p
}