diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index d96a5de8f..e8a47594f 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -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 = 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), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b6fb8f181..fa1edfda3 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -762,7 +762,7 @@ pub fn tools() -> Vec { 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"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a068c5267..624bcb539 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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"); diff --git a/crates/compare/src/lib.rs b/crates/compare/src/lib.rs index ac2e9b374..5141f93f1 100644 --- a/crates/compare/src/lib.rs +++ b/crates/compare/src/lib.rs @@ -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 { + 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); + } +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 9d7d00a5e..ef022bc37 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -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, 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>, EditError> { let c = self.compare(old, new)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7475e7521..21714d3af 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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")); diff --git a/crates/ui-egui/src/compare_ui.rs b/crates/ui-egui/src/compare_ui.rs index 523067403..83a395e6a 100644 --- a/crates/ui-egui/src/compare_ui.rs +++ b/crates/ui-egui/src/compare_ui.rs @@ -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, } +/// 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, 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() diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3da6a95bc..68f9dffda 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"