mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
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:
co-authored by
Claude Opus 5.5
parent
c12b795ce5
commit
a4bf717c06
@@ -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),
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user