M12: Compare files (new compare crate): word-level text differences, Compare panel with shading on the new file, report PDF, differences as comments; doc_compare, doc_compare_report, doc_compare_mark tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 05:31:18 -04:00
co-authored by Claude Opus 5.5
parent aa194686f1
commit c12b795ce5
24 changed files with 941 additions and 28 deletions
Generated
+5
View File
@@ -3786,6 +3786,10 @@ dependencies = [
"serde_json",
]
[[package]]
name = "printcraft-compare"
version = "0.1.0"
[[package]]
name = "printcraft-content"
version = "0.1.0"
@@ -3853,6 +3857,7 @@ dependencies = [
"png 0.18.1",
"printcraft-a11y",
"printcraft-annot",
"printcraft-compare",
"printcraft-cos",
"printcraft-create",
"printcraft-edit",
+1
View File
@@ -30,6 +30,7 @@ printcraft-optimize = { path = "crates/optimize", version = "0.1.0" }
printcraft-a11y = { path = "crates/a11y", version = "0.1.0" }
printcraft-ocr = { path = "crates/ocr", version = "0.1.0" }
printcraft-js = { path = "crates/js", version = "0.1.0" }
printcraft-compare = { path = "crates/compare", version = "0.1.0" }
printcraft-edit = { path = "crates/edit", version = "0.1.0" }
printcraft-create = { path = "crates/create", version = "0.1.0" }
printcraft-forms = { path = "crates/forms", version = "0.1.0" }
+50
View File
@@ -259,6 +259,56 @@ impl Automation {
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
}
fn compare_ids(&self, a: &Args) -> Result<(printcraft_engine::DocId, printcraft_engine::DocId)> {
let new = self.doc(a)?.id;
let other = a.int("other")?;
let old = u64::try_from(other)
.ok()
.and_then(|o| self.session.get(printcraft_engine::DocId(o)))
.map(|d| d.id)
.ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?;
Ok((old, new))
}
pub(crate) fn doc_compare(&self, a: &Args) -> Result<Value> {
use printcraft_engine::compare::Kind;
let (old, new) = self.compare_ids(a)?;
let c = self.session.compare(old, new).map_err(failed)?;
let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize;
let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0);
let side =
|s: &printcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::<Vec<_>>() });
let list: Vec<Value> = c
.changes
.iter()
.take(limit)
.map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) }))
.collect();
Ok(json!({
"identical": c.identical(),
"replaced": c.count(Kind::Replaced),
"inserted": c.count(Kind::Inserted),
"deleted": c.count(Kind::Deleted),
"old_words": c.old_words,
"new_words": c.new_words,
"changes": list,
}))
}
pub(crate) fn doc_compare_report(&self, a: &Args) -> Result<Value> {
let (old, new) = self.compare_ids(a)?;
let bytes = self.session.compare_report(old, new).map_err(failed)?;
let path = self.resolve(a.str("path")?, true)?;
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() }))
}
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
let (old, new) = self.compare_ids(a)?;
let n = self.session.mark_differences(old, new).map_err(failed)?;
Ok(json!({ "comments": n }))
}
pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result<Value> {
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
let id = self.doc(a)?.id;
+3
View File
@@ -511,6 +511,9 @@ impl Automation {
"form_merge_data" => self.form_merge_data(&a)?,
"form_actions" => self.form_actions(&a)?,
"form_detect_fields" => self.form_detect_fields(&a)?,
"doc_compare" => self.doc_compare(&a)?,
"doc_compare_report" => self.doc_compare_report(&a)?,
"doc_compare_mark" => self.doc_compare_mark(&a)?,
"form_set_actions" => self.form_set_actions(&a)?,
"ocr_status" => self.ocr_status()?,
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
+10
View File
@@ -759,6 +759,16 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_compare", "Compare files", "Compare the text of two open documents: other is the older version, doc the newer. Returns counts and each change (replaced, inserted, deleted) with the old and new text, pages (1-based) and rectangles (points, origin bottom-left).")
.ro()
.cmd("doc.compare")
.with(schema(json!({ "doc": doc(), "other": { "type": "integer", "description": "The older document (from doc_open)." }, "limit": { "type": "integer", "minimum": 1, "description": "List at most this many changes (default 500)." } }), &["doc", "other"])),
t("doc_compare_report", "Compare report", "Write the compare summary report (a PDF listing every change) to path.")
.cmd("doc.compare")
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" }, "path": { "type": "string" } }), &["doc", "other", "path"])),
t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.")
.cmd("doc.compare")
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])),
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
.cmd("form.detect")
.with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])),
+19
View File
@@ -1433,3 +1433,22 @@ fn detecting_form_fields_through_tools() {
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}");
}
#[test]
fn comparing_documents_through_tools() {
let dir = workdir("compare");
let mut a = auto(&dir);
let v1 = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Rent is 900 per month. Pets are not allowed." }))["doc"].as_u64().unwrap();
let v2 = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Rent is 950 per month. Pets are allowed. Parking included." }))["doc"]
.as_u64()
.unwrap();
let r = ok(&mut a, "doc_compare", json!({ "doc": v2, "other": v1 }));
assert_eq!((r["replaced"].as_u64(), r["inserted"].as_u64(), r["deleted"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
assert_eq!(r["changes"][0]["old"]["text"], "900");
assert_eq!(r["changes"][0]["new"]["text"], "950");
assert_eq!(r["changes"][0]["new"]["page"], 1);
ok(&mut a, "doc_compare_report", json!({ "doc": v2, "other": v1, "path": "report.pdf" }));
assert!(std::fs::read(dir.join("report.pdf")).unwrap().starts_with(b"%PDF"));
assert_eq!(ok(&mut a, "doc_compare_mark", json!({ "doc": v2, "other": v1 }))["comments"], 3);
assert!(a.call("doc_compare", &json!({ "doc": v2, "other": 999 })).is_err());
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "printcraft-compare"
description = "Compare files: word-level text differences between two documents, grouped into changes with their places on the pages"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
+396
View File
@@ -0,0 +1,396 @@
//! printcraft-compare — Compare files: text differences (L4).
//!
//! Both documents' words (in reading order, with page and box) are diffed with Myers'
//! O((N+M)·D) algorithm after the common prefix and suffix are set aside; the edit script is
//! grouped into [`Change`]s — inserted, deleted or replaced runs of words — each with the
//! rectangles to highlight on the old and new pages.
/// A word of one document.
#[derive(Clone, Debug, PartialEq)]
pub struct Word {
pub text: String,
/// 0-based page.
pub page: usize,
/// [x0, y0, x1, y1] in the page's user space.
pub rect: [f64; 4],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Inserted,
Deleted,
Replaced,
}
impl Kind {
pub fn label(self) -> &'static str {
match self {
Kind::Inserted => "Inserted",
Kind::Deleted => "Deleted",
Kind::Replaced => "Replaced",
}
}
}
/// One side of a change: its words' text, page and rectangles (merged per line).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Side {
pub text: String,
/// The page the change is on (for a pure insertion or deletion, where the other side's
/// words would have been).
pub page: usize,
pub rects: Vec<[f64; 4]>,
/// For an empty side (the other side's words were inserted or deleted): the box of the
/// word next to where they would be.
pub near: Option<[f64; 4]>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Change {
pub kind: Kind,
pub old: Side,
pub new: Side,
}
/// What the comparison found.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Comparison {
pub changes: Vec<Change>,
/// Words in each document.
pub old_words: usize,
pub new_words: usize,
}
impl Comparison {
pub fn count(&self, kind: Kind) -> usize {
self.changes.iter().filter(|c| c.kind == kind).count()
}
/// Whether the documents' text is the same.
pub fn identical(&self) -> bool {
self.changes.is_empty()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Op {
Equal,
Delete,
Insert,
}
/// Segments longer than this (old + new words) are first split at anchors, so Myers' saved
/// frontiers (O(D²) memory) stay small.
const DIRECT: usize = 3000;
/// The edit script for `a` → `b`.
fn diff(a: &[u64], b: &[u64], out: &mut Vec<Op>, depth: usize) {
let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let suffix = a[prefix..].iter().rev().zip(b[prefix..].iter().rev()).take_while(|(x, y)| x == y).count();
out.extend(std::iter::repeat_n(Op::Equal, prefix));
let (ma, mb) = (&a[prefix..a.len() - suffix], &b[prefix..b.len() - suffix]);
let replace = |out: &mut Vec<Op>| out.extend(std::iter::repeat_n(Op::Delete, ma.len()).chain(std::iter::repeat_n(Op::Insert, mb.len())));
if ma.len() + mb.len() <= DIRECT {
out.extend(myers(ma, mb));
} else {
let anchors = if depth > 8 { Vec::new() } else { anchors(ma, mb) };
if anchors.is_empty() {
// A long stretch with nothing in common: one replacement.
replace(out);
} else {
let (mut i, mut j) = (0, 0);
for (x, y) in anchors {
diff(&ma[i..x], &mb[j..y], out, depth + 1);
out.push(Op::Equal);
i = x + 1;
j = y + 1;
}
diff(&ma[i..], &mb[j..], out, depth + 1);
}
}
out.extend(std::iter::repeat_n(Op::Equal, suffix));
}
/// Patience anchors: words that occur once in each side, in an order both sides agree on
/// (the longest increasing subsequence of their positions).
fn anchors(a: &[u64], b: &[u64]) -> Vec<(usize, usize)> {
use std::collections::HashMap;
let mut count: HashMap<u64, (usize, usize, usize, usize)> = HashMap::new();
for (i, h) in a.iter().enumerate() {
let e = count.entry(*h).or_insert((0, 0, 0, 0));
e.0 += 1;
e.2 = i;
}
for (j, h) in b.iter().enumerate() {
if let Some(e) = count.get_mut(h) {
e.1 += 1;
e.3 = j;
}
}
let mut pairs: Vec<(usize, usize)> = count.values().filter(|e| e.0 == 1 && e.1 == 1).map(|e| (e.2, e.3)).collect();
pairs.sort();
// LIS on the b positions (patience sorting with back links).
let mut tails: Vec<usize> = Vec::new();
let mut prev = vec![usize::MAX; pairs.len()];
for (k, &(_, y)) in pairs.iter().enumerate() {
let pos = tails.partition_point(|&t| pairs[t].1 < y);
if pos > 0 {
prev[k] = tails[pos - 1];
}
if pos == tails.len() {
tails.push(k);
} else {
tails[pos] = k;
}
}
let mut out = Vec::new();
let mut k = tails.last().copied().unwrap_or(usize::MAX);
while k != usize::MAX {
out.push(pairs[k]);
k = prev[k];
}
out.reverse();
out
}
/// Myers' diff of `a` and `b`: the edit script.
fn myers(a: &[u64], b: &[u64]) -> Vec<Op> {
let (n, m) = (a.len(), b.len());
if n == 0 || m == 0 {
return std::iter::repeat_n(Op::Delete, n).chain(std::iter::repeat_n(Op::Insert, m)).collect();
}
let max = n + m;
let off = max as isize + 1;
let mut v = vec![0usize; 2 * max + 3];
// Frontier d only spans diagonals -d..=d: save just that slice.
let mut trace: Vec<Vec<usize>> = Vec::new();
let mut found = false;
'outer: for d in 0..=max as isize {
trace.push(v[(off - d - 1).max(0) as usize..=(off + d + 1) as usize].to_vec());
let mut k = -d;
while k <= d {
let i = (k + off) as usize;
let mut x = if k == -d || (k != d && v[i - 1] < v[i + 1]) { v[i + 1] } else { v[i - 1] + 1 };
let mut y = (x as isize - k) as usize;
while x < n && y < m && a[x] == b[y] {
x += 1;
y += 1;
}
v[i] = x;
if x >= n && y >= m {
found = true;
break 'outer;
}
k += 2;
}
}
if !found {
// Too different: replace everything.
return std::iter::repeat_n(Op::Delete, n).chain(std::iter::repeat_n(Op::Insert, m)).collect();
}
// Walk back through the saved frontiers.
let mut ops = Vec::new();
let (mut x, mut y) = (n as isize, m as isize);
for d in (0..trace.len() as isize).rev() {
let v = &trace[d as usize];
let k = x - y;
let base = (off - d - 1).max(0);
let i = |k: isize| (k + off - base) as usize;
let prev_k = if k == -d || (k != d && v[i(k - 1)] < v[i(k + 1)]) { k + 1 } else { k - 1 };
let prev_x = v[i(prev_k)] as isize;
let prev_y = prev_x - prev_k;
while x > prev_x && y > prev_y {
ops.push(Op::Equal);
x -= 1;
y -= 1;
}
if d > 0 {
ops.push(if x == prev_x { Op::Insert } else { Op::Delete });
}
x = prev_x;
y = prev_y;
}
ops.reverse();
ops
}
fn hash(s: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
/// Merge word boxes on the same page and line into one rectangle per line.
fn rects(words: &[&Word]) -> Vec<[f64; 4]> {
let mut out: Vec<[f64; 4]> = Vec::new();
for w in words {
let r = w.rect;
match out.last_mut() {
Some(l) if (l[1] - r[1]).abs() < (r[3] - r[1]).max(1.0) * 0.5 && r[0] - l[2] < (r[3] - r[1]).max(4.0) * 2.0 && r[0] >= l[0] - 1.0 => {
l[0] = l[0].min(r[0]);
l[1] = l[1].min(r[1]);
l[2] = l[2].max(r[2]);
l[3] = l[3].max(r[3]);
}
_ => out.push(r),
}
}
out
}
fn side(words: &[&Word], neighbour: Option<&Word>) -> Side {
Side {
text: words.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "),
page: words.first().or(neighbour.as_ref()).map_or(0, |w| w.page),
rects: rects(words),
near: if words.is_empty() { neighbour.map(|w| w.rect) } else { None },
}
}
/// Compare two documents' words.
pub fn compare(old: &[Word], new: &[Word]) -> Comparison {
let a: Vec<u64> = old.iter().map(|w| hash(&w.text)).collect();
let b: Vec<u64> = new.iter().map(|w| hash(&w.text)).collect();
let mut ops = Vec::with_capacity(a.len().max(b.len()));
diff(&a, &b, &mut ops, 0);
let mut changes = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut k = 0;
while k < ops.len() {
if ops[k] == Op::Equal {
i += 1;
j += 1;
k += 1;
continue;
}
let (mut del, mut ins): (Vec<&Word>, Vec<&Word>) = (Vec::new(), Vec::new());
while k < ops.len() && ops[k] != Op::Equal {
match ops[k] {
Op::Delete => {
del.push(&old[i]);
i += 1;
}
_ => {
ins.push(&new[j]);
j += 1;
}
}
k += 1;
}
let kind = match (del.is_empty(), ins.is_empty()) {
(false, false) => Kind::Replaced,
(false, true) => Kind::Deleted,
_ => Kind::Inserted,
};
// Where a one-sided change sits in the other document: next to the neighbouring word.
let old_near = old.get(i).or_else(|| old.last());
let new_near = new.get(j).or_else(|| new.last());
changes.push(Change { kind, old: side(&del, old_near), new: side(&ins, new_near) });
}
Comparison { changes, old_words: old.len(), new_words: new.len() }
}
/// The compare report's text: a summary, then each change with its pages.
pub fn report(c: &Comparison, old_name: &str, new_name: &str) -> String {
let mut s = format!(
"Compare Report\n\nOld file: {old_name}\nNew file: {new_name}\n\n{} changes: {} replaced, {} inserted, {} deleted.\n",
c.changes.len(),
c.count(Kind::Replaced),
c.count(Kind::Inserted),
c.count(Kind::Deleted)
);
if c.identical() {
s.push_str("\nThe documents' text is identical.\n");
}
for (n, ch) in c.changes.iter().enumerate() {
s.push_str(&format!("\n{}. {} (old page {}, new page {})\n", n + 1, ch.kind.label(), ch.old.page + 1, ch.new.page + 1));
if !ch.old.text.is_empty() {
s.push_str(&format!(" Old: {}\n", ch.old.text));
}
if !ch.new.text.is_empty() {
s.push_str(&format!(" New: {}\n", ch.new.text));
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn words(text: &str, page: usize) -> Vec<Word> {
text.split_whitespace()
.enumerate()
.map(|(i, t)| Word { text: t.into(), page, rect: [10.0 + 40.0 * i as f64, 700.0, 45.0 + 40.0 * i as f64, 710.0] })
.collect()
}
#[test]
fn identical_text_has_no_changes() {
let c = compare(&words("the same words here", 0), &words("the same words here", 0));
assert!(c.identical());
}
#[test]
fn insertions_deletions_and_replacements() {
let old = words("The quick brown fox jumps over the lazy dog", 0);
let new = words("The quick red fox leaps over the very lazy dog today", 0);
let c = compare(&old, &new);
let got: Vec<(Kind, &str, &str)> = c.changes.iter().map(|x| (x.kind, x.old.text.as_str(), x.new.text.as_str())).collect();
assert_eq!(
got,
[(Kind::Replaced, "brown", "red"), (Kind::Replaced, "jumps", "leaps"), (Kind::Inserted, "", "very"), (Kind::Inserted, "", "today"),]
);
assert_eq!(c.changes[0].new.rects, [[90.0, 700.0, 125.0, 710.0]]);
assert_eq!(c.count(Kind::Inserted), 2);
}
#[test]
fn deletions_across_pages_keep_their_pages() {
let mut old = words("alpha beta gamma", 0);
old.extend(words("delta epsilon", 1));
let mut new = words("alpha gamma", 0);
new.extend(words("delta", 1));
let c = compare(&old, &new);
let got: Vec<(Kind, usize, &str)> = c.changes.iter().map(|x| (x.kind, x.old.page, x.old.text.as_str())).collect();
assert_eq!(got, [(Kind::Deleted, 0, "beta"), (Kind::Deleted, 1, "epsilon")]);
// The deletion's place in the new document: the page of the words around it.
assert_eq!(c.changes[1].new.page, 1);
let r = report(&c, "v1.pdf", "v2.pdf");
assert!(r.contains("2 changes: 0 replaced, 0 inserted, 2 deleted.") && r.contains("Old: epsilon"), "{r}");
}
#[test]
fn adjacent_words_merge_into_one_rectangle_per_line() {
let old = words("a b c", 0);
let new = words("x y z", 0);
let c = compare(&old, &new);
assert_eq!(c.changes.len(), 1);
assert_eq!(c.changes[0].new.rects.len(), 1);
}
#[test]
fn long_documents_with_scattered_edits() {
let text: Vec<String> = (0..20_000).map(|i| format!("w{i}")).collect();
let old: Vec<Word> = text.iter().map(|t| Word { text: t.clone(), page: 0, rect: [0.0; 4] }).collect();
let mut new = old.clone();
for k in [100usize, 5000, 12_000, 19_000] {
new[k].text = format!("changed{k}");
}
new.remove(8000);
let c = compare(&old, &new);
assert_eq!(c.changes.len(), 5, "{:?}", c.changes.iter().map(|x| (x.kind, &x.old.text, &x.new.text)).collect::<Vec<_>>());
assert_eq!(c.count(Kind::Replaced), 4);
}
#[test]
fn very_different_documents_finish() {
let old: Vec<Word> = (0..30_000).map(|i| Word { text: format!("a{i}"), page: 0, rect: [0.0; 4] }).collect();
let new: Vec<Word> = (0..30_000).map(|i| Word { text: format!("b{i}"), page: 0, rect: [0.0; 4] }).collect();
let c = compare(&old, &new);
assert_eq!(c.changes.len(), 1);
assert_eq!(c.changes[0].kind, Kind::Replaced);
}
}
+1
View File
@@ -24,6 +24,7 @@ printcraft-optimize = { workspace = true }
printcraft-a11y = { workspace = true }
printcraft-ocr = { workspace = true }
printcraft-js = { workspace = true }
printcraft-compare = { workspace = true }
printcraft-fonts = { workspace = true }
printcraft-create = { workspace = true }
printcraft-model = { workspace = true }
+2 -2
View File
@@ -413,8 +413,8 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
icon: "git-compare",
hue: PINK,
badge: None,
availability: Planned("M12"),
sections: &[ToolSection { title: "Compare", items: &[item("Select files to compare", "git-compare", "compare.files", Planned("M12"))] }],
availability: Ready,
sections: &[ToolSection { title: "Compare", items: &[item("Select files to compare", "git-compare", "doc.compare", Ready)] }],
},
ToolGroup {
id: "actions",
+1
View File
@@ -201,6 +201,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"),
c("form.import_data", "Import form data…", None, None, HasFields, "file-input"),
c("form.export_data", "Export form data…", None, None, HasFields, "file-output"),
c("doc.compare", "Compare files…", None, None, Document, "git-compare"),
c("form.detect", "Detect form fields", None, None, Modification, "scan"),
c("form.merge_data", "Merge data files into spreadsheet…", None, None, Nothing, "file-spreadsheet"),
c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"),
+83
View File
@@ -0,0 +1,83 @@
//! Compare files: the text differences between two open documents, a report, and the
//! differences marked as comments in the newer one.
use std::sync::Arc;
pub use printcraft_compare::{Change, Comparison, Kind, Side};
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
/// Acrobat's compare colours: replaced blue, inserted green, deleted red.
pub fn colour(kind: Kind) -> crate::Rgb {
match kind {
Kind::Replaced => [0.2, 0.45, 0.95],
Kind::Inserted => [0.2, 0.75, 0.3],
Kind::Deleted => [0.9, 0.25, 0.25],
}
}
impl crate::Document {
/// Every word of the document in reading order, with page and box.
pub fn words(&self) -> Vec<printcraft_compare::Word> {
let config = printcraft_render::RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() };
let mut r = printcraft_render::PageRenderer::new(self.bytes.clone(), config);
let mut out = Vec::new();
for (page, info) in self.info.pages.iter().enumerate() {
let res =
r.render(printcraft_render::RenderRequest { page, kind: printcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
if let Some(t) = res.text {
out.extend(crate::js::page_words(&t, info).into_iter().map(|(text, rect)| printcraft_compare::Word { text, page, rect }));
}
}
out
}
}
impl Session {
/// Compare files: the text differences from document `old` to document `new`.
pub fn compare(&self, old: DocId, new: DocId) -> Result<Comparison, EditError> {
let a = self.get(old).ok_or(EditError::NoDocument)?;
let b = self.get(new).ok_or(EditError::NoDocument)?;
Ok(printcraft_compare::compare(&a.words(), &b.words()))
}
/// The compare report as a new PDF (not opened).
pub fn compare_report(&self, old: DocId, new: DocId) -> Result<Arc<Vec<u8>>, EditError> {
let c = self.compare(old, new)?;
let name = |id| self.get(id).map(|d| d.name.clone()).unwrap_or_default();
self.create_from_text("Compare Report", &printcraft_compare::report(&c, &name(old), &name(new)))
}
/// Mark the differences in `new` as comments: highlights over replaced and inserted text
/// (blue, green) and a note where text was deleted (red), authored "Compare". One undoable
/// step; returns how many comments were added.
pub fn mark_differences(&mut self, old: DocId, new: DocId) -> Result<usize, EditError> {
let c = self.compare(old, new)?;
let mut edits = Vec::new();
for ch in &c.changes {
let color = colour(ch.kind);
let contents = match ch.kind {
Kind::Replaced => format!("Replaced: \"{}\" with \"{}\"", ch.old.text, ch.new.text),
Kind::Inserted => format!("Inserted: \"{}\"", ch.new.text),
Kind::Deleted => format!("Deleted: \"{}\"", ch.old.text),
};
let shape = if ch.new.rects.is_empty() {
let Some(r) = ch.new.near else { continue };
Shape::Note { at: [r[0], r[3]], icon: NoteIcon::Note }
} else {
Shape::TextMarkup {
kind: Markup::Highlight,
quads: ch.new.rects.iter().map(|r| [r[0], r[3], r[2], r[3], r[0], r[1], r[2], r[1]]).collect(),
}
};
let mut style = crate::Style::default_for(&shape);
style.color = color;
edits.push(Edit::AddAnnotation(NewAnnotation { page: ch.new.page, shape, style, contents, author: "Compare".into() }));
}
let n = edits.len();
if n > 0 {
self.apply(new, Edit::Batch { label: "Mark differences".into(), edits })?;
}
Ok(n)
}
}
+7 -1
View File
@@ -229,6 +229,12 @@ impl Session {
/// A page's words for form-field detection, in user space (underscore runs split from the
/// text around them).
fn detection_words(text: &printcraft_render::PageText, info: &printcraft_render::PageInfo) -> Vec<printcraft_forms::detect::Word> {
page_words(text, info).into_iter().map(|(text, rect)| printcraft_forms::detect::Word { text, rect }).collect()
}
/// A page's words in reading order, in user space ([x0, y0, x1, y1]); runs of underscores are
/// words of their own.
pub(crate) fn page_words(text: &printcraft_render::PageText, info: &printcraft_render::PageInfo) -> Vec<(String, [f64; 4])> {
let mut words: Vec<(String, [f32; 4])> = Vec::new();
let mut last_line = u32::MAX;
// A space glyph ends the word before it.
@@ -263,7 +269,7 @@ fn detection_words(text: &printcraft_render::PageText, info: &printcraft_render:
let a = info.view_to_user(r[0], r[1]);
let b = info.view_to_user(r[2], r[3]);
let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64];
printcraft_forms::detect::Word { text, rect }
(text, rect)
})
.collect()
}
+1
View File
@@ -12,6 +12,7 @@
pub mod catalog;
pub mod commands;
pub mod compare;
pub mod export;
pub mod js;
pub mod links;
+27
View File
@@ -1318,3 +1318,30 @@ fn detecting_fields_on_a_printed_form() {
assert!(f.widgets[0].rect[0] > 72.0 + 20.0, "{:?}", f.widgets[0].rect);
assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect");
}
#[test]
fn comparing_two_versions_of_a_document() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let v1 = s.create_from_text("t", "The contract starts on Monday.\n\nPayment is due within thirty days.\n\nSigned by both parties.").unwrap();
let v2 = s
.create_from_text("t", "The contract starts on Tuesday.\n\nPayment is due within thirty days of invoice.\n\nSigned by both parties.")
.unwrap();
let old = s.open("v1.pdf", None, v1, None).unwrap();
let new = s.open("v2.pdf", None, v2, None).unwrap();
let c = s.compare(old, new).unwrap();
let got: Vec<(compare::Kind, &str, &str)> = c.changes.iter().map(|x| (x.kind, x.old.text.as_str(), x.new.text.as_str())).collect();
assert_eq!(got, [(compare::Kind::Replaced, "Monday.", "Tuesday."), (compare::Kind::Replaced, "days.", "days of invoice.")], "{got:?}");
assert!(s.compare(old, old).unwrap().identical());
let report = s.compare_report(old, new).unwrap();
let rid = s.open("report.pdf", None, report, None).unwrap();
let text = page_texts(&s, rid).join(" ");
assert!(text.contains("2 changes: 2 replaced") && text.contains("Tuesday."), "{text}");
assert_eq!(s.mark_differences(old, new).unwrap(), 2);
let d = s.get(new).unwrap();
assert_eq!(d.can_undo(), Some("Mark differences"));
let marks: Vec<_> = d.info.annotations.iter().filter(|a| a.author.as_deref() == Some("Compare")).collect();
assert_eq!(marks.len(), 2);
assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\""));
}
+9
View File
@@ -103,6 +103,8 @@ pub struct DocView {
pub goto: Option<(usize, f32)>,
/// Briefly outline an annotation after navigating to it from a panel.
pub flash: Option<(usize, [f32; 4], f64)>,
/// Compare files: differences shaded on this document's pages (page, user-space box, colour).
pub compare_marks: Vec<(usize, [f32; 4], Color32)>,
pages: HashMap<usize, PageTex>,
/// Pages the renderer could not draw, with the reason (never re-requested).
errors: HashMap<usize, String>,
@@ -221,6 +223,7 @@ impl DocView {
forward: Vec::new(),
goto: None,
flash: None,
compare_marks: Vec::new(),
pages: HashMap::new(),
errors: HashMap::new(),
waiting_since: HashMap::new(),
@@ -1384,6 +1387,12 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
}
for (mp, mr, mc) in &view.compare_marks {
if *mp == i {
let sr = xf.user_rect(info, i, *mr).expand(1.5);
painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28));
}
}
if let Some((fp, fr, t0)) = view.flash
&& fp == i
{
+1
View File
@@ -243,6 +243,7 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
(RightPanel::Signatures, "Signatures"),
(RightPanel::Accessibility, "Accessibility Checker"),
(RightPanel::Search, "Search"),
(RightPanel::Compare, "Compare"),
] {
if ui.radio(app.right == Some(p), label).clicked() {
app.right = Some(p);
+1
View File
@@ -373,6 +373,7 @@ impl PrintCraftApp {
}
}
"form.detect" => self.detect_fields(),
"doc.compare" => self.dialog = Some(Dialog::CompareFiles),
"form.field.properties" => {
if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) {
self.open_field_props(&name, w);
+193
View File
@@ -0,0 +1,193 @@
//! Compare files: choose the older version, then the Compare panel lists the differences
//! (replaced blue, inserted green, deleted red), shades them on the newer document's pages, and
//! offers a report or the differences as comments.
use egui::{Align, Color32, Layout};
use printcraft_engine::DocId;
use printcraft_engine::compare::{Comparison, Kind};
use crate::theme::{self, Tokens};
use crate::{PrintCraftApp, RightPanel, widgets};
/// The last comparison.
pub struct CompareState {
pub old: DocId,
pub new: DocId,
pub result: Comparison,
pub selected: Option<usize>,
}
/// What the panel asks for.
pub enum PanelAction {
Select(usize),
Mark,
Report,
Clear,
}
pub fn colour(kind: Kind) -> Color32 {
let [r, g, b] = printcraft_engine::compare::colour(kind);
Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
}
/// The Compare Files dialog: pick the older document. Returns (compare, cancel).
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) {
ui.label(egui::RichText::new("Compare Files").font(theme::semibold(18.0)));
ui.add_space(8.0);
let Some((_, new)) = app.active_ids() else { return (false, true) };
let others: Vec<(DocId, String)> =
app.views.iter().filter(|v| v.id != new).filter_map(|v| app.session.get(v.id).map(|d| (d.id, d.name.clone()))).collect();
if app.compare_old.is_none_or(|o| !others.iter().any(|(id, _)| *id == o)) {
app.compare_old = others.first().map(|(id, _)| *id);
}
let new_name = app.session.get(new).map(|d| d.name.clone()).unwrap_or_default();
egui::Grid::new("compare-files").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| {
ui.label("Old file:");
let shown = others.iter().find(|(id, _)| Some(*id) == app.compare_old).map_or("Open the older version first", |(_, n)| n.as_str());
egui::ComboBox::from_id_salt("compare-old").selected_text(shown).width(320.0).show_ui(ui, |ui| {
for (id, n) in &others {
ui.selectable_value(&mut app.compare_old, Some(*id), n);
}
});
ui.end_row();
ui.label("New file:");
ui.label(egui::RichText::new(new_name).strong());
ui.end_row();
});
ui.add_space(4.0);
ui.label(egui::RichText::new("Text is compared word by word; the differences are shown on the new file.").small().color(t.text_muted));
ui.add_space(10.0);
let (mut go, mut cancel) = (false, false);
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(app.compare_old.is_some(), |ui| widgets::pill_button(ui, "Compare", true)).inner.clicked() {
go = true;
}
if widgets::pill_button(ui, "Cancel", false).clicked() {
cancel = true;
}
})
});
(go, cancel)
}
/// The Compare panel.
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option<CompareState>, id: DocId) -> Option<PanelAction> {
let Some(s) = state.as_ref().filter(|s| s.new == id) else {
ui.label(egui::RichText::new("Compare this file with an older version: Compare files… in All tools.").color(t.text_muted));
return None;
};
let mut action = None;
let c = &s.result;
if c.identical() {
ui.label("No differences in the text.");
}
ui.horizontal_wrapped(|ui| {
for k in [Kind::Replaced, Kind::Inserted, Kind::Deleted] {
let (dot, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().rect_filled(dot, egui::CornerRadius::same(2), colour(k));
ui.label(format!("{} {}", c.count(k), k.label()));
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("Mark as comments").clicked() {
action = Some(PanelAction::Mark);
}
if ui.button("Report…").clicked() {
action = Some(PanelAction::Report);
}
if ui.button("Clear").clicked() {
action = Some(PanelAction::Clear);
}
});
ui.separator();
for (i, ch) in c.changes.iter().enumerate().take(2000) {
let text = match ch.kind {
Kind::Replaced => format!("\"{}\" → \"{}\"", ch.old.text, ch.new.text),
Kind::Inserted => format!("+ \"{}\"", ch.new.text),
Kind::Deleted => format!("− \"{}\"", ch.old.text),
};
let selected = s.selected == Some(i);
let r = ui.push_id(i, |ui| {
ui.horizontal(|ui| {
let (bar, _) = ui.allocate_exact_size(egui::vec2(4.0, 34.0), egui::Sense::hover());
ui.painter().rect_filled(bar, egui::CornerRadius::same(2), colour(ch.kind));
ui.vertical(|ui| {
ui.label(egui::RichText::new(format!("{} · page {}", ch.kind.label(), ch.new.page + 1)).small().color(t.text_muted));
ui.selectable_label(selected, text)
})
.inner
})
.inner
});
if r.inner.clicked() {
action = Some(PanelAction::Select(i));
}
}
action
}
impl PrintCraftApp {
/// Compare the chosen older document with the active one and show the differences.
pub fn run_compare(&mut self) {
let Some((i, new)) = self.active_ids() else { return };
let Some(old) = self.compare_old else { return };
match self.session.compare(old, new) {
Ok(result) => {
self.views[i].compare_marks = result
.changes
.iter()
.flat_map(|ch| ch.new.rects.iter().map(move |r| (ch.new.page, r.map(|v| v as f32), colour(ch.kind))))
.collect();
let n = result.changes.len();
self.compare = Some(crate::compare_ui::CompareState { old, new, result, selected: None });
self.right = Some(RightPanel::Compare);
self.notify(if n == 0 {
"The text is the same".to_string()
} else {
format!("{n} difference{} found", if n == 1 { "" } else { "s" })
});
}
Err(e) => self.notify(e.to_string()),
}
}
pub(crate) fn compare_action(&mut self, index: usize, a: PanelAction) {
let Some(s) = self.compare.as_mut() else { return };
let (old, new) = (s.old, s.new);
match a {
PanelAction::Select(i) => {
s.selected = Some(i);
let Some(ch) = s.result.changes.get(i) else { return };
let rect = ch.new.rects.first().copied().or(ch.new.near).unwrap_or_default();
let page = ch.new.page;
let v = &mut self.views[index];
v.go_to_page(page);
v.flash = Some((page, rect.map(|x| x as f32), 0.0));
}
PanelAction::Mark => match self.session.mark_differences(old, new) {
Ok(n) => {
if let Some(info) = self.session.get(new).map(|d| d.info.clone()) {
self.views[index].document_changed(&info);
}
self.notify(format!("Added {n} comment{}", if n == 1 { "" } else { "s" }));
}
Err(e) => self.notify(e.to_string()),
},
PanelAction::Report => match self.session.compare_report(old, new) {
Ok(bytes) => {
if let Err(e) = self.open_bytes("Compare Report.pdf", None, bytes.to_vec()) {
self.notify(e);
}
}
Err(e) => self.notify(e.to_string()),
},
PanelAction::Clear => {
self.compare = None;
self.views[index].compare_marks.clear();
self.right = None;
}
}
}
}
+10
View File
@@ -51,6 +51,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut open_revision: Option<usize> = None;
let mut a11y_now = false;
let mut ocr_now = false;
let mut compare_now = false;
let mut combine_now = false;
let mut stamp_now = false;
let mut alt_now = false;
@@ -819,6 +820,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
close = go || cancel;
return;
}
Dialog::CompareFiles => {
let (go, cancel) = crate::compare_ui::body(ui, app, &t);
compare_now = go;
close = go || cancel;
return;
}
Dialog::JsConsole => {
ui.set_width(640.0);
close = crate::js_ui::console_body(ui, app, &t);
@@ -1208,6 +1215,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
if ocr_now {
app.start_ocr();
}
if compare_now {
app.run_compare();
}
if a11y_now {
app.a11y_skipped.clear();
app.run_accessibility_check();
+12
View File
@@ -13,6 +13,7 @@ mod commands;
mod comment_props;
pub mod comments;
mod comments_panel;
mod compare_ui;
pub mod control;
mod create_ui;
mod crop;
@@ -96,6 +97,8 @@ pub enum RightPanel {
Accessibility,
/// Advanced Search results.
Search,
/// Compare files: the differences.
Compare,
}
/// Quick-action bar tools (the vertical floating strip).
@@ -194,6 +197,8 @@ pub enum Dialog {
DocumentJs,
/// Preferences.
Preferences,
/// Compare files: choose the older version.
CompareFiles,
/// Combine files: the files, their order and pages.
Combine,
/// Custom stamps ▸ Create.
@@ -309,6 +314,9 @@ pub struct PrintCraftApp {
pub ocr_run: Option<ocr_ui::OcrRun>,
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
pub ocr_sync: bool,
/// Compare files: the chosen older document and the last result.
pub compare_old: Option<DocId>,
pub compare: Option<compare_ui::CompareState>,
/// The JavaScript console and the Document JavaScripts draft.
pub js_console: js_ui::JsConsole,
pub doc_js: js_ui::DocJsDraft,
@@ -451,6 +459,8 @@ impl PrintCraftApp {
ocr_run: None,
ocr_batch: None,
ocr_sync: false,
compare_old: None,
compare: None,
js_console: Default::default(),
doc_js: Default::default(),
a11y: a11y_ui::A11yState::default(),
@@ -857,6 +867,7 @@ impl PrintCraftApp {
"signatures" => Some(RightPanel::Signatures),
"accessibility" => Some(RightPanel::Accessibility),
"search" => Some(RightPanel::Search),
"compare" => Some(RightPanel::Compare),
"none" => None,
other => return Err(format!("unknown panel {other}")),
}
@@ -898,6 +909,7 @@ impl PrintCraftApp {
"js-console" => Some(Dialog::JsConsole),
"document-js" => Some(Dialog::DocumentJs),
"preferences" => Some(Dialog::Preferences),
"compare-files" => Some(Dialog::CompareFiles),
"signature" => Some(Dialog::Signature),
"optimize" => Some(Dialog::Optimize),
"sign" | "certify" => {
+7
View File
@@ -364,6 +364,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
let mut panel_command: Option<&'static str> = None;
let mut sig_action: Option<crate::sign_ui::PanelAction> = None;
let mut a11y_action: Option<crate::a11y_ui::PanelAction> = None;
let mut compare_action: Option<crate::compare_ui::PanelAction> = None;
let mut bm_rename = app.bookmark_rename.clone();
let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none());
// Prepare a form is open: the Fields panel orders tabs.
@@ -375,6 +376,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
let prefs = &app.comment_prefs;
let sig_expanded = &mut app.sig_expanded;
let a11y = &mut app.a11y;
let compare = &app.compare;
let comment_allowed = doc.allows_annotation();
egui::Panel::right("right_panel")
.resizable(true)
@@ -397,6 +399,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
RightPanel::Signatures => ("Signatures", Some(doc.signatures.iter().filter(|s| s.signed).count())),
RightPanel::Accessibility => ("Accessibility Checker", None),
RightPanel::Search => ("Search", None),
RightPanel::Compare => ("Compare", compare.as_ref().filter(|c| c.new == id).map(|c| c.result.changes.len())),
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new(title).font(theme::semibold(15.5)));
@@ -498,6 +501,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
a11y_action = crate::a11y_ui::panel(ui, &t, a11y, id);
}
RightPanel::Search => crate::search_ui::panel(ui, &t, view, info.pages.len()),
RightPanel::Compare => compare_action = crate::compare_ui::panel(ui, &t, compare, id),
RightPanel::Attachments => {
if info.attachments.is_empty() {
empty(ui, &t, "paperclip", "This document has no attachments.");
@@ -547,6 +551,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
if let Some(a) = a11y_action {
app.a11y_action(index, a);
}
if let Some(a) = compare_action {
app.compare_action(index, a);
}
match sig_action {
Some(crate::sign_ui::PanelAction::Validate) => app.run_command("sign.validate"),
Some(crate::sign_ui::PanelAction::GoTo(p)) => app.views[index].go_to_page(p),
+64
View File
@@ -0,0 +1,64 @@
//! Compare files in the real shell (egui_kittest): the dialog, the Compare panel, marks and
//! the report.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use printcraft_engine::Session;
use printcraft_ui_egui::{PrintCraftApp, RightPanel};
#[test]
fn compare_two_versions() {
let s = Session::new();
let v1 = s.create_from_text("t", "Delivery within five days. Returns accepted.").unwrap().to_vec();
let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("v1.pdf", None, v1.clone()).unwrap();
app.open_bytes("v2.pdf", None, v2.clone()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("doc.compare"));
h.run_steps(2);
h.get_by_label("Compare Files");
h.get_by_label("Compare").click();
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Compare));
assert_eq!(h.state().toast.clone().unwrap().0, "2 differences found");
h.get_by_label("2 Replaced");
h.get_by_label("\"five\" → \"three\"").click();
h.run_steps(2);
assert_eq!(h.state().compare.as_ref().unwrap().selected, Some(0));
let id = h.state().active_ids().unwrap().1;
let i = h.state().active_ids().unwrap().0;
assert_eq!(h.state().views[i].compare_marks.len(), 2);
h.get_by_label("Mark as comments").click();
h.run_steps(3);
assert_eq!(h.state().session.get(id).unwrap().info.annotations.len(), 2);
h.get_by_label("Report…").click();
h.run_steps(3);
assert_eq!(h.state().session.get(h.state().active_ids().unwrap().1).unwrap().name, "Compare Report.pdf");
}
/// Writes a screenshot of the Compare panel when PRINTCRAFT_SHOT is set (for review).
#[test]
fn compare_panel_screenshot() {
let Ok(out) = std::env::var("PRINTCRAFT_SHOT") else { return };
let s = Session::new();
let v1 = s.create_from_text("t", "Delivery within five days. Returns accepted.").unwrap().to_vec();
let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("v1.pdf", None, v1.clone()).unwrap();
app.open_bytes("v2.pdf", None, v2.clone()).unwrap();
app
});
h.run_steps(4);
h.state_mut().compare_old = h.state().views.first().map(|v| v.id);
h.state_mut().run_compare();
for _ in 0..20 {
h.run_steps(2);
std::thread::sleep(std::time::Duration::from_millis(50));
}
h.render().unwrap().save(out).unwrap();
}
+25 -25
View File
@@ -6411,11 +6411,11 @@ title = "Skip pages that already have text"
area = "I"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["ocr.recognize"]
tools = ["ocr_recognize"]
evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable"]
notes = "Pages that already have text are skipped and reported (skip_text_pages false reads them anyway)."
[[feature]]
id = "ocr.rotation-detection"
@@ -8975,11 +8975,11 @@ title = "Compare files: text differences"
area = "N"
tier = "P1"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["doc.compare"]
tools = ["doc_compare", "doc_compare_mark"]
evidence = ["crates/compare/src/lib.rs::insertions_deletions_and_replacements", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"]
notes = "Word-level Myers diff with patience anchors for long files; replaced/inserted/deleted shaded on the new file, listed in the Compare panel, or added as comments."
[[feature]]
id = "misc.compare-visual"
@@ -9011,11 +9011,11 @@ title = "Compare summary report as PDF"
area = "N"
tier = "P1"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["doc.compare"]
tools = ["doc_compare_report"]
evidence = ["crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools", "crates/ui-egui/tests/compare.rs::compare_two_versions"]
notes = "Summary report as a new PDF (counts and every change with pages)."
[[feature]]
id = "misc.compare-filters"
@@ -9623,11 +9623,11 @@ title = "Preferences dialog generated from a schema"
area = "N"
tier = "P1"
milestone = "M14"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["app.preferences"]
tools = ["js_enabled"]
evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"]
notes = "Preferences dialog with the JavaScript category; other categories still live in their tools."
[[feature]]
id = "misc.prefs-general"
@@ -9815,11 +9815,11 @@ title = "Preferences: JavaScript"
area = "N"
tier = "P1"
milestone = "M14"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["app.preferences"]
tools = ["js_enabled"]
evidence = ["crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"]
notes = "Enable Acrobat JavaScript, remembered between runs."
[[feature]]
id = "misc.prefs-measuring"