M12: Compare files: visual differences (pages rendered and compared region by region), shaded orange; doc_compare visual option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 05:37:23 -04:00
co-authored by Claude Opus 5.5
parent c12b795ce5
commit a4bf717c06
8 changed files with 159 additions and 8 deletions
+6
View File
@@ -284,7 +284,13 @@ impl Automation {
.take(limit)
.map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) }))
.collect();
let visual: Vec<Value> = if a.opt_bool("visual")?.unwrap_or(false) {
self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect()
} else {
Vec::new()
};
Ok(json!({
"visual": visual,
"identical": c.identical(),
"replaced": c.count(Kind::Replaced),
"inserted": c.count(Kind::Inserted),
+1 -1
View File
@@ -762,7 +762,7 @@ pub fn tools() -> Vec<ToolDef> {
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"])),
.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)." }, "visual": { "type": "boolean", "description": "Also compare how pages look (page n with page n): regions that differ visually." } }), &["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"])),
+2 -1
View File
@@ -1442,7 +1442,8 @@ fn comparing_documents_through_tools() {
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 }));
let r = ok(&mut a, "doc_compare", json!({ "doc": v2, "other": v1, "visual": true }));
assert!(!r["visual"].as_array().unwrap().is_empty(), "{r}");
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");
+90
View File
@@ -394,3 +394,93 @@ mod tests {
assert_eq!(c.changes[0].kind, Kind::Replaced);
}
}
/// Visual compare: the regions where two renderings of a page differ, as pixel boxes
/// `[x0, y0, x1, y1]` (y down) in `a`'s image. Both images are RGBA; `b` is sampled at the
/// same relative position when sizes differ. Pixels count as different when a channel differs
/// by more than `tolerance`; differing cells of a coarse grid are joined into regions.
pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8) -> Vec<[u32; 4]> {
let (pa, wa, ha) = a;
let (pb, wb, hb) = b;
if wa == 0 || ha == 0 || wb == 0 || hb == 0 {
return Vec::new();
}
const CELL: u32 = 8;
let (gw, gh) = (wa.div_ceil(CELL), ha.div_ceil(CELL));
let mut grid = vec![false; (gw * gh) as usize];
for y in 0..ha {
let yb = (y as u64 * hb as u64 / ha as u64) as u32;
for x in 0..wa {
let xb = (x as u64 * wb as u64 / wa as u64) as u32;
let ia = ((y * wa + x) * 4) as usize;
let ib = ((yb * wb + xb) * 4) as usize;
let (Some(ca), Some(cb)) = (pa.get(ia..ia + 3), pb.get(ib..ib + 3)) else { continue };
if ca.iter().zip(cb).any(|(p, q)| p.abs_diff(*q) > tolerance) {
grid[((y / CELL) * gw + x / CELL) as usize] = true;
}
}
}
// Connected cells (8-neighbourhood, with a one-cell gap bridged) become one region.
let mut seen = vec![false; grid.len()];
let mut out = Vec::new();
for start in 0..grid.len() {
if !grid[start] || seen[start] {
continue;
}
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
let mut stack = vec![start];
seen[start] = true;
while let Some(c) = stack.pop() {
let (cx, cy) = (c as u32 % gw, c as u32 / gw);
x0 = x0.min(cx);
y0 = y0.min(cy);
x1 = x1.max(cx);
y1 = y1.max(cy);
for dy in -2i64..=2 {
for dx in -2i64..=2 {
let (nx, ny) = (cx as i64 + dx, cy as i64 + dy);
if nx < 0 || ny < 0 || nx >= gw as i64 || ny >= gh as i64 {
continue;
}
let n = (ny as u32 * gw + nx as u32) as usize;
if grid[n] && !seen[n] {
seen[n] = true;
stack.push(n);
}
}
}
}
out.push([x0 * CELL, y0 * CELL, ((x1 + 1) * CELL).min(wa), ((y1 + 1) * CELL).min(ha)]);
}
out
}
#[cfg(test)]
mod visual_tests {
use super::*;
fn image(w: u32, h: u32, boxes: &[[u32; 4]]) -> Vec<u8> {
let mut px = vec![255u8; (w * h * 4) as usize];
for b in boxes {
for y in b[1]..b[3] {
for x in b[0]..b[2] {
let i = ((y * w + x) * 4) as usize;
px[i..i + 3].copy_from_slice(&[0, 0, 0]);
}
}
}
px
}
#[test]
fn finds_changed_regions() {
let a = image(200, 200, &[[10, 10, 50, 20]]);
let b = image(200, 200, &[[10, 10, 50, 20], [120, 150, 160, 170]]);
assert!(visual_regions((&a, 200, 200), (&a, 200, 200), 16).is_empty());
let r = visual_regions((&a, 200, 200), (&b, 200, 200), 16);
assert_eq!(r, [[120, 144, 160, 176]]);
// Two nearby marks join; distant ones don't.
let c = image(200, 200, &[[10, 10, 50, 20], [100, 100, 104, 104], [110, 100, 114, 104], [10, 180, 14, 184]]);
assert_eq!(visual_regions((&a, 200, 200), (&c, 200, 200), 16).len(), 2);
}
}
+31
View File
@@ -41,6 +41,37 @@ impl Session {
Ok(printcraft_compare::compare(&a.words(), &b.words()))
}
/// Visual compare: regions where page n of `new` looks different from page n of `old`
/// (rendered at `dpi`), as (page, user-space box in `new`).
pub fn compare_visual(&self, old: DocId, new: DocId, dpi: f32) -> Result<Vec<(usize, [f64; 4])>, EditError> {
let a = self.get(old).ok_or(EditError::NoDocument)?;
let b = self.get(new).ok_or(EditError::NoDocument)?;
let renderer = |d: &crate::Document| {
printcraft_render::PageRenderer::new(
d.bytes.clone(),
printcraft_render::RenderConfig { password: d.password.as_deref().map(Arc::from), ..Default::default() },
)
};
let (mut ra, mut rb) = (renderer(a), renderer(b));
let scale = dpi.clamp(18.0, 150.0) / 72.0;
let mut out = Vec::new();
for page in 0..a.info.pages.len().min(b.info.pages.len()) {
let req = printcraft_render::RenderRequest { page, scale, ..Default::default() };
let (x, y) = (ra.render(req), rb.render(req));
if x.error.is_some() || y.error.is_some() {
continue;
}
let info = &b.info.pages[page];
let s = y.width as f32 / info.width.max(1e-3);
for r in printcraft_compare::visual_regions((&y.rgba, y.width, y.height), (&x.rgba, x.width, x.height), 24) {
let p = info.view_to_user(r[0] as f32 / s, r[1] as f32 / s);
let q = info.view_to_user(r[2] as f32 / s, r[3] as f32 / s);
out.push((page, [p[0].min(q[0]) as f64, p[1].min(q[1]) as f64, p[0].max(q[0]) as f64, p[1].max(q[1]) as f64]));
}
}
Ok(out)
}
/// 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)?;
+5
View File
@@ -1338,6 +1338,11 @@ fn comparing_two_versions_of_a_document() {
let text = page_texts(&s, rid).join(" ");
assert!(text.contains("2 changes: 2 replaced") && text.contains("Tuesday."), "{text}");
// Visually, only the changed words' places differ (and the page is otherwise the same).
let vis = s.compare_visual(old, new, 72.0).unwrap();
assert!(!vis.is_empty() && vis.iter().all(|(p, r)| *p == 0 && r[3] > 600.0), "{vis:?}");
assert!(s.compare_visual(old, old, 72.0).unwrap().is_empty());
assert_eq!(s.mark_differences(old, new).unwrap(), 2);
let d = s.get(new).unwrap();
assert_eq!(d.can_undo(), Some("Mark differences"));
+19 -1
View File
@@ -14,9 +14,14 @@ pub struct CompareState {
pub old: DocId,
pub new: DocId,
pub result: Comparison,
/// Regions that look different (page, user-space box), page n against page n.
pub visual: Vec<(usize, [f64; 4])>,
pub selected: Option<usize>,
}
/// Visual differences are shaded orange.
pub const VISUAL: Color32 = Color32::from_rgb(0xF0, 0x8C, 0x1A);
/// What the panel asks for.
pub enum PanelAction {
Select(usize),
@@ -88,6 +93,9 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option<CompareState>,
ui.painter().rect_filled(dot, egui::CornerRadius::same(2), colour(k));
ui.label(format!("{} {}", c.count(k), k.label()));
}
let (dot, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().rect_filled(dot, egui::CornerRadius::same(2), VISUAL);
ui.label(format!("{} Visual", s.visual.len()));
});
ui.add_space(6.0);
ui.horizontal(|ui| {
@@ -135,13 +143,23 @@ impl PrintCraftApp {
let Some(old) = self.compare_old else { return };
match self.session.compare(old, new) {
Ok(result) => {
let visual = self.session.compare_visual(old, new, 72.0).unwrap_or_default();
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();
// Visual differences not already covered by a text change.
let text_marks = self.views[i].compare_marks.clone();
for (p, r) in &visual {
let r = r.map(|v| v as f32);
let covered = text_marks.iter().any(|(mp, m, _)| *mp == *p && m[0] <= r[2] && r[0] <= m[2] && m[1] <= r[3] && r[1] <= m[3]);
if !covered {
self.views[i].compare_marks.push((*p, r, VISUAL));
}
}
let n = result.changes.len();
self.compare = Some(crate::compare_ui::CompareState { old, new, result, selected: None });
self.compare = Some(crate::compare_ui::CompareState { old, new, result, visual, selected: None });
self.right = Some(RightPanel::Compare);
self.notify(if n == 0 {
"The text is the same".to_string()
+5 -5
View File
@@ -8987,11 +8987,11 @@ title = "Compare files: visual differences"
area = "N"
tier = "P1"
milestone = "M12"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["doc.compare"]
tools = ["doc_compare"]
evidence = ["crates/compare/src/lib.rs::finds_changed_regions", "crates/engine/src/tests.rs::comparing_two_versions_of_a_document", "crates/automation/tests/automation.rs::comparing_documents_through_tools"]
notes = "Pages rendered at 72 dpi and compared page by page; differing regions shaded orange where no text change explains them."
[[feature]]
id = "misc.compare-side-by-side"