1use crate::component_graph::ComponentGraph;
10use crate::djvu_document::{DjVuBookmark, DjVuDocument, DocError};
11use crate::metadata::DjVuMetadata;
12use crate::text::{TextZone, TextZoneKind};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PlaneStatus {
17 Match,
19 Diverge,
21}
22
23#[derive(Debug, Clone)]
25pub struct PlaneDiff {
26 pub plane: &'static str,
29 pub status: PlaneStatus,
31 pub details: Vec<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct SemanticDiff {
38 pub planes: Vec<PlaneDiff>,
40}
41
42impl SemanticDiff {
43 pub fn is_identical(&self) -> bool {
45 self.planes
46 .iter()
47 .all(|plane| plane.status == PlaneStatus::Match)
48 }
49}
50
51pub const PLANES: [&str; 6] = [
53 "pages",
54 "text",
55 "annotations",
56 "metadata",
57 "bookmarks",
58 "component_graph",
59];
60
61const MAX_DETAILS: usize = 8;
63
64pub fn semantic_diff(
71 a: &[u8],
72 b: &[u8],
73 planes: Option<&[String]>,
74) -> Result<SemanticDiff, DocError> {
75 let doc_a = DjVuDocument::parse(a)?;
76 let doc_b = DjVuDocument::parse(b)?;
77
78 let wanted =
79 |name: &str| -> bool { planes.is_none_or(|list| list.iter().any(|plane| plane == name)) };
80
81 let mut result = SemanticDiff { planes: Vec::new() };
82 if wanted("pages") {
83 result.planes.push(diff_pages(&doc_a, &doc_b)?);
84 }
85 if wanted("text") {
86 result.planes.push(diff_text(&doc_a, &doc_b)?);
87 }
88 if wanted("annotations") {
89 result.planes.push(diff_annotations(&doc_a, &doc_b)?);
90 }
91 if wanted("metadata") {
92 result.planes.push(diff_metadata(&doc_a, &doc_b)?);
93 }
94 if wanted("bookmarks") {
95 result.planes.push(diff_bookmarks(&doc_a, &doc_b));
96 }
97 if wanted("component_graph") {
98 result.planes.push(diff_component_graph(a, b));
99 }
100 Ok(result)
101}
102
103fn plane(plane: &'static str, details: Vec<String>) -> PlaneDiff {
104 let status = if details.is_empty() {
105 PlaneStatus::Match
106 } else {
107 PlaneStatus::Diverge
108 };
109 let mut details = details;
110 if details.len() > MAX_DETAILS {
111 let hidden = details.len() - MAX_DETAILS;
112 details.truncate(MAX_DETAILS);
113 details.push(format!("... and {hidden} more"));
114 }
115 PlaneDiff {
116 plane,
117 status,
118 details,
119 }
120}
121
122fn text_signature(value: &str) -> String {
124 value.split_whitespace().collect::<Vec<_>>().join(" ")
125}
126
127fn excerpt(value: &str) -> String {
129 const MAX: usize = 48;
130 let mut out: String = value.chars().take(MAX).collect();
131 if value.chars().count() > MAX {
132 out.push('…');
133 }
134 out
135}
136
137fn diff_pages(a: &DjVuDocument, b: &DjVuDocument) -> Result<PlaneDiff, DocError> {
138 let mut details = Vec::new();
139 if a.page_count() != b.page_count() {
140 details.push(format!(
141 "page count differs: {} vs {}",
142 a.page_count(),
143 b.page_count()
144 ));
145 }
146 for index in 0..a.page_count().min(b.page_count()) {
147 let pa = a.page(index)?;
148 let pb = b.page(index)?;
149 let props_a = (pa.width(), pa.height(), pa.dpi());
150 let props_b = (pb.width(), pb.height(), pb.dpi());
151 if props_a != props_b {
152 details.push(format!(
153 "page {}: {}x{} @ {} dpi vs {}x{} @ {} dpi",
154 index + 1,
155 props_a.0,
156 props_a.1,
157 props_a.2,
158 props_b.0,
159 props_b.1,
160 props_b.2,
161 ));
162 }
163 }
164 Ok(plane("pages", details))
165}
166
167fn diff_text(a: &DjVuDocument, b: &DjVuDocument) -> Result<PlaneDiff, DocError> {
168 let mut details = Vec::new();
169 if a.page_count() != b.page_count() {
170 details.push(format!(
171 "page count differs: {} vs {}",
172 a.page_count(),
173 b.page_count()
174 ));
175 }
176 for index in 0..a.page_count().min(b.page_count()) {
177 let text_a = a.page(index)?.text()?.map(|t| text_signature(&t));
178 let text_b = b.page(index)?.text()?.map(|t| text_signature(&t));
179 if text_a != text_b {
180 details.push(format!(
181 "page {}: \"{}\" vs \"{}\"",
182 index + 1,
183 excerpt(text_a.as_deref().unwrap_or("<no text layer>")),
184 excerpt(text_b.as_deref().unwrap_or("<no text layer>")),
185 ));
186 }
187 }
188 Ok(plane("text", details))
189}
190
191fn diff_annotations(a: &DjVuDocument, b: &DjVuDocument) -> Result<PlaneDiff, DocError> {
192 let mut details = Vec::new();
193 if a.page_count() != b.page_count() {
194 details.push(format!(
195 "page count differs: {} vs {}",
196 a.page_count(),
197 b.page_count()
198 ));
199 }
200 for index in 0..a.page_count().min(b.page_count()) {
201 let sig_a = a.page(index)?.annotations()?.map(|v| format!("{v:?}"));
204 let sig_b = b.page(index)?.annotations()?.map(|v| format!("{v:?}"));
205 if sig_a != sig_b {
206 details.push(format!(
207 "page {}: annotations differ ({} vs {})",
208 index + 1,
209 sig_a.map_or("absent".to_string(), |s| excerpt(&s)),
210 sig_b.map_or("absent".to_string(), |s| excerpt(&s)),
211 ));
212 }
213 }
214 Ok(plane("annotations", details))
215}
216
217fn metadata_signature(meta: &DjVuMetadata) -> String {
218 let mut pairs = Vec::new();
219 let mut push = |key: &str, value: &Option<String>| {
220 if let Some(value) = value {
221 pairs.push(format!("{key}={}", text_signature(value)));
222 }
223 };
224 push("title", &meta.title);
225 push("author", &meta.author);
226 push("subject", &meta.subject);
227 push("publisher", &meta.publisher);
228 push("year", &meta.year);
229 push("keywords", &meta.keywords);
230 let mut extra: Vec<String> = meta
231 .extra
232 .iter()
233 .map(|(key, value)| format!("{}={}", key.to_ascii_lowercase(), text_signature(value)))
234 .collect();
235 pairs.append(&mut extra);
236 pairs.sort();
237 pairs.join("\n")
238}
239
240fn diff_metadata(a: &DjVuDocument, b: &DjVuDocument) -> Result<PlaneDiff, DocError> {
241 let sig_a = a.metadata()?.as_ref().map(metadata_signature);
242 let sig_b = b.metadata()?.as_ref().map(metadata_signature);
243 let mut details = Vec::new();
244 if sig_a != sig_b {
245 details.push(format!(
246 "metadata differs: {} vs {}",
247 sig_a.map_or("absent".to_string(), |s| excerpt(&s.replace('\n', "; "))),
248 sig_b.map_or("absent".to_string(), |s| excerpt(&s.replace('\n', "; "))),
249 ));
250 }
251 Ok(plane("metadata", details))
252}
253
254fn bookmark_signature(bookmarks: &[DjVuBookmark], out: &mut String, depth: usize) {
255 for bookmark in bookmarks {
256 out.push_str(&format!(
257 "{}{}|{}\n",
258 " ".repeat(depth),
259 text_signature(&bookmark.title),
260 text_signature(&bookmark.url),
261 ));
262 bookmark_signature(&bookmark.children, out, depth + 1);
263 }
264}
265
266fn diff_bookmarks(a: &DjVuDocument, b: &DjVuDocument) -> PlaneDiff {
267 let sig = |doc: &DjVuDocument| {
268 let mut out = String::new();
269 bookmark_signature(doc.bookmarks(), &mut out, 0);
270 out
271 };
272 let sig_a = sig(a);
273 let sig_b = sig(b);
274 let mut details = Vec::new();
275 if sig_a != sig_b {
276 let first = sig_a
278 .lines()
279 .zip(sig_b.lines())
280 .find(|(la, lb)| la != lb)
281 .map(|(la, lb)| format!("first differing entry: \"{la}\" vs \"{lb}\""))
282 .unwrap_or_else(|| {
283 format!(
284 "bookmark count differs: {} vs {} entries",
285 sig_a.lines().count(),
286 sig_b.lines().count()
287 )
288 });
289 details.push(first);
290 }
291 plane("bookmarks", details)
292}
293
294fn zone_kind_name(kind: &TextZoneKind) -> &'static str {
295 match kind {
296 TextZoneKind::Page => "page",
297 TextZoneKind::Column => "column",
298 TextZoneKind::Region => "region",
299 TextZoneKind::Para => "para",
300 TextZoneKind::Line => "line",
301 TextZoneKind::Word => "word",
302 TextZoneKind::Character => "char",
303 }
304}
305
306#[allow(dead_code)]
309fn zone_signature(zone: &TextZone, out: &mut String, depth: usize) {
310 out.push_str(&format!(
311 "{}{}:{}\n",
312 " ".repeat(depth),
313 zone_kind_name(&zone.kind),
314 text_signature(&zone.text),
315 ));
316 for child in &zone.children {
317 zone_signature(child, out, depth + 1);
318 }
319}
320
321fn diff_component_graph(a: &[u8], b: &[u8]) -> PlaneDiff {
322 let graph_a = ComponentGraph::parse(a).ok();
323 let graph_b = ComponentGraph::parse(b).ok();
324 let mut details = Vec::new();
325 match (&graph_a, &graph_b) {
326 (None, None) => {} (Some(_), None) => {
328 details.push("only the first document has a bundled component graph".to_string());
329 }
330 (None, Some(_)) => {
331 details.push("only the second document has a bundled component graph".to_string());
332 }
333 (Some(ga), Some(gb)) => {
334 let seq = |graph: &ComponentGraph| {
336 graph
337 .nodes()
338 .iter()
339 .map(|node| format!("{}:{:?}", node.id, node.kind))
340 .collect::<Vec<_>>()
341 };
342 let seq_a = seq(ga);
343 let seq_b = seq(gb);
344 if seq_a != seq_b {
345 details.push(format!(
346 "DIRM sequence differs: [{}] vs [{}]",
347 excerpt(&seq_a.join(", ")),
348 excerpt(&seq_b.join(", ")),
349 ));
350 }
351 let edges = |graph: &ComponentGraph| {
353 let mut edges: Vec<String> = graph
354 .nodes()
355 .iter()
356 .flat_map(|node| {
357 node.includes
358 .iter()
359 .map(|&target| format!("{}->{}", node.id, graph.nodes()[target].id))
360 })
361 .collect();
362 edges.sort();
363 edges
364 };
365 let edges_a = edges(ga);
366 let edges_b = edges(gb);
367 if edges_a != edges_b {
368 for edge in edges_a.iter().filter(|edge| !edges_b.contains(edge)) {
369 details.push(format!("INCL edge only in first: {edge}"));
370 }
371 for edge in edges_b.iter().filter(|edge| !edges_a.contains(edge)) {
372 details.push(format!("INCL edge only in second: {edge}"));
373 }
374 }
375 }
376 }
377 plane("component_graph", details)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn fixture(name: &str) -> Vec<u8> {
385 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
386 .join("tests")
387 .join("fixtures")
388 .join(name);
389 std::fs::read(path).expect("fixture exists")
390 }
391
392 #[test]
393 fn identical_documents_match_on_every_plane() {
394 let bytes = fixture("DjVu3Spec_bundled.djvu");
395 let diff = semantic_diff(&bytes, &bytes, None).expect("diff");
396 assert!(diff.is_identical(), "planes: {:?}", diff.planes);
397 assert_eq!(diff.planes.len(), PLANES.len());
398 }
399
400 #[test]
401 fn different_documents_diverge() {
402 let a = fixture("DjVu3Spec_bundled.djvu");
403 let b = std::fs::read(
404 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
405 .join("tests")
406 .join("corpus")
407 .join("cable_1973_100133.djvu"),
408 )
409 .expect("corpus fixture");
410 let diff = semantic_diff(&a, &b, None).expect("diff");
411 assert!(!diff.is_identical());
412 let pages = diff
414 .planes
415 .iter()
416 .find(|plane| plane.plane == "pages")
417 .expect("pages plane present");
418 assert_eq!(pages.status, PlaneStatus::Diverge);
419 }
420
421 #[test]
422 fn plane_filter_limits_evaluation() {
423 let bytes = fixture("DjVu3Spec_bundled.djvu");
424 let planes = vec!["text".to_string()];
425 let diff = semantic_diff(&bytes, &bytes, Some(&planes)).expect("diff");
426 assert_eq!(diff.planes.len(), 1);
427 assert_eq!(diff.planes[0].plane, "text");
428 }
429
430 #[test]
431 fn metadata_only_edit_diverges_only_expected_planes() {
432 use crate::editor::{DocumentEditor, EditOperation, EditRequest};
433 use crate::metadata::DjVuMetadata;
434
435 let original = fixture("DjVu3Spec_bundled.djvu");
436 let edited = DocumentEditor::apply(
437 &original,
438 &EditRequest::new(vec![EditOperation::SetDocumentMetadata {
439 metadata: DjVuMetadata {
440 title: Some("Edited title".to_string()),
441 ..Default::default()
442 },
443 }]),
444 )
445 .expect("edit applies");
446
447 let diff = semantic_diff(&original, &edited, None).expect("diff");
448 for plane in &diff.planes {
449 match plane.plane {
450 "metadata" => assert_eq!(plane.status, PlaneStatus::Diverge),
451 _ => assert_eq!(
454 plane.status,
455 PlaneStatus::Match,
456 "unexpected divergence in {}: {:?}",
457 plane.plane,
458 plane.details
459 ),
460 }
461 }
462 }
463
464 #[test]
465 fn details_are_bounded() {
466 let details: Vec<String> = (0..40).map(|i| format!("detail {i}")).collect();
467 let plane = plane("pages", details);
468 assert!(plane.details.len() <= MAX_DETAILS + 1);
469 assert!(plane.details.last().unwrap().contains("more"));
470 }
471}