From 0548b5f5faaf943b7301c29f4035f9557e3e3fcd Mon Sep 17 00:00:00 2001 From: Ada Bohm Date: Sat, 11 Jan 2025 22:06:03 +0100 Subject: [PATCH] WIP --- v2/Cargo.lock | 311 ++++++++++++++++++++++++++++ v2/kompari-html/Cargo.toml | 6 + v2/kompari-html/src/lib.rs | 21 +- v2/kompari-html/src/pageconsts.rs | 330 ++++++++++++++++++++++++++++++ v2/kompari-html/src/report.rs | 210 +++++++++++++++++++ v2/kompari/src/dirdiff.rs | 38 ++-- v2/kompari/src/imgdiff.rs | 34 ++- v2/kompari/src/lib.rs | 8 +- v2/kompari/src/tests.rs | 43 +++- 9 files changed, 974 insertions(+), 27 deletions(-) create mode 100644 v2/kompari-html/src/pageconsts.rs create mode 100644 v2/kompari-html/src/report.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 1680228..b98f996 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -8,18 +8,45 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + [[package]] name = "bytemuck" version = "1.21.0" @@ -32,12 +59,41 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "cc" +version = "1.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0cf6e91fde44c773c6ee7ec6bba798504641a8bc2eb7e37a04ffbf4dfaa55a" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crc32fast" version = "1.4.2" @@ -66,6 +122,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "image" version = "0.25.5" @@ -78,6 +157,28 @@ dependencies = [ "png", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kompari" version = "0.1.0" @@ -93,11 +194,53 @@ version = "0.1.0" [[package]] name = "kompari-html" version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "image", + "imagesize", + "kompari", + "maud", +] [[package]] name = "kompari-tasks" version = "0.1.0" +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "log" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6ea2a48c204030ee31a7d7fc72c93294c92fe87ecb1789881c9543516e1a0d" + +[[package]] +name = "maud" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "miniz_oxide" version = "0.8.2" @@ -117,6 +260,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + [[package]] name = "png" version = "0.17.16" @@ -130,6 +279,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.92" @@ -148,6 +320,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "simd-adler32" version = "0.3.7" @@ -190,3 +368,136 @@ name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/v2/kompari-html/Cargo.toml b/v2/kompari-html/Cargo.toml index b4e5f0a..964b4ff 100644 --- a/v2/kompari-html/Cargo.toml +++ b/v2/kompari-html/Cargo.toml @@ -7,3 +7,9 @@ license.workspace = true repository.workspace = true [dependencies] +kompari = { path = "../kompari" } +image = { workspace = true } +base64 = "0.22" +chrono = "0.4" +maud = "0.26" +imagesize = "0.13" diff --git a/v2/kompari-html/src/lib.rs b/v2/kompari-html/src/lib.rs index b93cf3f..6e39755 100644 --- a/v2/kompari-html/src/lib.rs +++ b/v2/kompari-html/src/lib.rs @@ -1,5 +1,22 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +mod pageconsts; +mod report; + +pub struct ReportConfig { + left_title: String, + right_title: String, + embed_images: bool, + is_review: bool, +} + +impl Default for ReportConfig { + fn default() -> Self { + ReportConfig { + left_title: "Left image".to_string(), + right_title: "Right image".to_string(), + embed_images: false, + is_review: false, + } + } } #[cfg(test)] diff --git a/v2/kompari-html/src/pageconsts.rs b/v2/kompari-html/src/pageconsts.rs new file mode 100644 index 0000000..8675d29 --- /dev/null +++ b/v2/kompari-html/src/pageconsts.rs @@ -0,0 +1,330 @@ +pub(crate) const ICON: &[u8] = include_bytes!("../../../docs/logo_small.png"); + +pub(crate) const CSS_STYLE: &str = " +body { + font-family: Roboto, sans-serif; + margin: 0; + padding: 20px; + background: #f5f5f5; + color: #333; +} + +.header { + background: #fff; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.logo { + vertical-align: -10%; +} + +.header h1 { + margin: 0; + color: #2d3748; +} + +.summary { + margin-bottom: 20px; + padding: 15px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.diff-entry { + background: #fff; + margin-bottom: 30px; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.diff-entry h2 { + margin-top: 0; + color: #2d3748; + border-bottom: 2px solid #edf2f7; + padding-bottom: 10px; +} + +.comparison-container { + display: flex; + gap: 20px; + margin-top: 15px; +} + +.image-container { + display: flex; + gap: 20px; + flex-wrap: wrap; + flex: 1; +} + +.image-box { + flex: 1; + min-width: 250px; + max-width: 400px; +} + +.image-box h3 { + margin: 0 0 10px 0; + color: #4a5568; + font-size: 1rem; +} + +.image-box img { + max-width: 100%; + border: 1px solid #e2e8f0; + border-radius: 4px; +} + +.stats-container { + width: 200px; + flex-shrink: 0; + background: #f8fafc; + padding: 15px; + border-radius: 6px; + border: 1px solid #e2e8f0; +} + +.stat-item { + margin-bottom: 15px; +} + +.stat-label { + font-size: 0.875rem; + color: #64748b; + margin-bottom: 4px; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 600; + color: #2d3748; +} + +.stat-value.ok { + color: #77d906; +} + +.stat-value.warning { + color: #d97706; +} + +.stat-value.error { + color: #dc2626; +} + +@media (max-width: 1200px) { + .comparison-container { + flex-direction: column-reverse; + } + + .stats-container { + width: auto; + display: flex; + flex-wrap: wrap; + gap: 20px; + } + + .stat-item { + flex: 1; + min-width: 150px; + margin-bottom: 0; + } +} + +@media (max-width: 768px) { + .image-box { + min-width: 100%; + } +} + +img.zoom:hover { + transform: scale(1.05); +} + +dialog { + width: 80%; + height: 80%; + max-width: 800px; + max-height: 820px; + padding: 0; + border: none; + border-radius: 10px; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.3); +} + +.zoomed-image { + object-fit: contain; + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-optimize-contrast; + -ms-interpolation-mode: nearest-neighbor; + image-rendering: pixelated; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 60px; + height: 30px; + margin-right: 1em; +} +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .1s; + border-radius: 34px; +} +.slider:before { + position: absolute; + content: \"\"; + height: 22px; + width: 22px; + left: 4px; + bottom: 4px; + background-color: white; + transition: .1s; + border-radius: 50%; +} +input:checked + .slider { + background-color: #3c3; +} +input:checked + .slider:before { + transform: translateX(30px); +} + +.accept-button { + padding: 12px 24px; + margin-bottom: 1em; + font-size: 16px; + font-weight: 500; + color: white; + background-color: #4CAF50; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 8px; +} +.accept-button:hover { + background-color: #45A049; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} +.accept-button:active { + transform: translateY(0px); + box-shadow: none; +} +.accept-button:disabled { + background-color: #CCCCCC; + cursor: not-allowed; + transform: none; +} +#errorMsg { + background-color: #fef2f2; + border: 1px solid #f87171; + border-radius: 6px; + padding: 16px; + margin: 12px 0; + display: none; + align-items: flex-start; + gap: 12px; + max-width: 600px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +"; + +pub(crate) const JS_CODE: &str = " +function openImageDialog(img) { + const dialog = document.getElementById('imageDialog'); + const zoomedImg = document.getElementById('zoomedImage'); + zoomedImg.src = img.src; + if (img.width < img.height) { + zoomedImg.style.width = \"100%\"; + zoomedImg.style.height = \"auto\"; + } else { + zoomedImg.style.width = \"auto\"; + zoomedImg.style.height = \"100%\"; + } + dialog.showModal(); +} + +function closeImageDialog() { + const dialog = document.getElementById('imageDialog'); + dialog.close(); +} + +document.getElementById('imageDialog').addEventListener('click', function(event) { + closeImageDialog(); +}); + +var selected = new Set(); +function toggle(event) { + let node = event.target.parentNode.parentNode; + let name = node.childNodes[2].textContent; + if (event.target.checked) { + selected.add(name); + node.style.color = \"#3a3\"; + } else { + selected.delete(name); + node.style.color = \"#333\"; + } + updateAcceptButton() +} + +function updateAcceptButton() { + let text = document.getElementById('acceptText'); + text.textContent = \"Accept selected cases (\" + selected.size + \" / \" + nTests + \")\"; + let button = document.getElementById('acceptButton'); + button.disabled = (selected.size === 0); +} + +async function acceptTests() { + let text = document.getElementById('acceptText'); + text.textContent = \"Updating \" + selected.size + \" cases ...\"; + let button = document.getElementById('acceptButton'); + button.disabled = true; + + try { + const url = '/update'; + const response = await fetch(url, { + method: 'POST', + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ accepted_names: Array.from(selected) }) + }); + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } else { + for (i = 0; i < nTests; i++) { + document.getElementById(\"t\" + i).checked = false; + } + location.reload(); + } + } catch (e) { + let error = document.getElementById('errorMsg'); + error.textContent = e.message; + error.style.display = \"flex\"; + text.textContent = \"Try update again\"; + button.disabled = false; + } +} +"; diff --git a/v2/kompari-html/src/report.rs b/v2/kompari-html/src/report.rs new file mode 100644 index 0000000..516552e --- /dev/null +++ b/v2/kompari-html/src/report.rs @@ -0,0 +1,210 @@ +// Copyright 2024 the Kompari Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::pageconsts::{CSS_STYLE, ICON, JS_CODE}; +use crate::ReportConfig; +use base64::prelude::*; +use chrono::SubsecRound; +use kompari::ImageDifference; +use maud::{html, Markup, PreEscaped, DOCTYPE}; +use std::io::Cursor; +use std::path::Path; + +const IMAGE_SIZE_LIMIT: u32 = 400; + +fn embed_png_url(data: &[u8]) -> String { + let mut url = "data:image/png;base64,".to_string(); + url.push_str(&base64::engine::general_purpose::STANDARD.encode(data)); + url +} + +fn render_image( + config: &ReportConfig, + path: &Path, + state: kompari::Result<()>, +) -> kompari::Result { + Ok(match state { + Ok(()) => { + let (path, size) = if config.embed_images { + let image_data = std::fs::read(path)?; + ( + embed_png_url(&image_data), + imagesize::blob_size(&image_data)?, + ) + } else { + (path.display().to_string(), imagesize::size(path)?) + }; + let (w, h) = html_size(size.width as u32, size.height as u32, IMAGE_SIZE_LIMIT); + html! { + img class="zoom" src=(path) width=[w] height=[h] onclick="openImageDialog(this)"; + } + } + Err(kompari::Error::FileNotFound(_)) => { + html! { "File is missing" } + } + Err(err) => { + html! { "Error: " (err) } + } + }) +} + +pub fn html_size(width: u32, height: u32, size_limit: u32) -> (Option, Option) { + if width > height { + (Some(width.min(size_limit)), None) + } else { + (None, Some(height.min(size_limit))) + } +} + +fn render_difference_image(difference: &kompari::Result) -> Markup { + match difference { + Ok(ImageDifference::Content { diff_image, .. }) => { + let (w, h) = html_size(diff_image.width(), diff_image.height(), IMAGE_SIZE_LIMIT); + let mut data = Vec::new(); + diff_image + .write_to(&mut Cursor::new(&mut data), image::ImageFormat::Png) + .unwrap(); + html! { + img class="zoom" src=(embed_png_url(&data)) width=[w] height=[h] onclick="openImageDialog(this)"; + } + } + _ => html!("N/A"), + } +} + +fn render_stat_item(label: &str, value_type: &str, value: &str) -> Markup { + html! { + div .stat-item { + div .stat-label { + (label) + } + @let value_class = format!("stat-value {}", value_type); + div class=(value_class) { + (value) + } + } + } +} + +fn render_difference_info( + config: &ReportConfig, + difference: kompari::Result, +) -> Markup { + match &difference { + Ok(ImageDifference::None) => render_stat_item("Status", "ok", "Match"), + Ok(ImageDifference::SizeMismatch { + left_size, + right_size, + }) => html! { + (render_stat_item("Status", "error", "Size mismatch")) + (render_stat_item(&format!("{} size", config.left_title), "", &format!("{}x{}", left_size.0, left_size.1))) + (render_stat_item(&format!("{} size", config.right_title), "", &format!("{}x{}", right_size.0, right_size.1))) + }, + Ok(ImageDifference::Content { + n_different_pixels, + distance_sum, + .. + }) => { + let size = &pair_diff.left_info.info().unwrap().size; + let n_pixels = size.width as f32 * size.height as f32; + let pct = *n_different_pixels as f32 / n_pixels * 100.0; + let distance_sum = *distance_sum as f32 / 255.0; // Normalize + let avg_color_distance = distance_sum / n_pixels; + html! { + (render_stat_item("Different pixels", "warning", &format!("{n_different_pixels} ({pct:.1}%)"))) + (render_stat_item("Color distance", "", &format!("{distance_sum:.3}"))) + (render_stat_item("Avg. color distance", "", &format!("{avg_color_distance:.4}"))) + } + } + + Difference::LoadError => render_stat_item("Status", "error", "Loading error"), + Difference::MissingFile => render_stat_item("Status", "error", "Missing file"), + } +} + +fn render_pair_diff( + config: &ReportConfig, + id: usize, + pair_diff: &PairResult, +) -> crate::Result { + Ok(html! { + div class="diff-entry" { + h2 { + @if config.is_review { + label class="toggle-switch" { + input type="checkbox" id=(format!("t{id}")); + span class="slider"; + } + script { + (format!("document.getElementById('t{id}').addEventListener('change', toggle)")) + } + } + (pair_diff.pair.title)}; + div class="comparison-container" { + div class="image-container" { + div class="stats-container" { + (render_difference_info(config, &pair_diff)) + } + div class="image-box" { + h3 { (config.left_title) } + (render_image(config, &pair_diff.left_info, &pair_diff.pair.left)?) + } + div class="image-box" { + h3 { (config.right_title) } + (render_image(config, &pair_diff.right_info, &pair_diff.pair.right)?) + } + div class="image-box" { + h3 { "Difference"} + (render_difference_image(&pair_diff.difference)) + } + } + } + } + }) +} + +pub(crate) fn render_html_report( + config: &ReportConfig, + diffs: &[PairResult], +) -> crate::Result { + let now = chrono::Local::now().round_subsecs(0); + let title = PreEscaped(if config.is_review { + "Kompari review" + } else { + "Kompari report" + }); + let report = html! { + (DOCTYPE) + html { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + meta name="generator" content=(format!("Kompari {}", env!("CARGO_PKG_VERSION"))); + title { (title) } + style { (PreEscaped(CSS_STYLE)) } + link rel="icon" type="image/png" href=(embed_png_url(&ICON)); + } + body { + div class="header" { + h1 { img class="logo" src=(embed_png_url(ICON)) width="32" height="32"; (title) } + p { "Generated on " (now) } + } + dialog id="imageDialog" { + img id="zoomedImage" class="zoomed-image" src="" alt="Zoomed Image"; + } + @if config.is_review { + script { (format!("const nTests = {};", diffs.len())) } + button class="accept-button" id="acceptButton" disabled onClick="acceptTests()" { + span class="button-text" id="acceptText" { (format!("Accept selected cases (0 / {})", diffs.len())) } + } + span id="errorMsg" {}; + } + script { (PreEscaped(JS_CODE)) } + @for (id, pair_diff) in diffs.iter().enumerate() { + (render_pair_diff(config, id, pair_diff)?) + } + } + } + }; + Ok(report.into_string()) +} diff --git a/v2/kompari/src/dirdiff.rs b/v2/kompari/src/dirdiff.rs index 72da743..cd242de 100644 --- a/v2/kompari/src/dirdiff.rs +++ b/v2/kompari/src/dirdiff.rs @@ -1,6 +1,6 @@ -use std::path::{Path, PathBuf}; use crate::fsutils::{list_image_dir_names, load_image}; use crate::imgdiff::{compare_images, ImageDifference}; +use std::path::{Path, PathBuf}; pub struct DirDiffConfig { left_path: PathBuf, @@ -11,7 +11,6 @@ pub struct DirDiffConfig { } impl DirDiffConfig { - pub fn new(left_path: PathBuf, right_path: PathBuf) -> Self { DirDiffConfig { left_path, @@ -32,11 +31,18 @@ impl DirDiffConfig { .into_iter() .filter_map(|pair| { let image_diff = compute_pair_diff(&pair); - if self.ignore_left_missing && !matches!(image_diff, Err(ref e) if e.is_left_missing()) { - return None + if matches!(image_diff, Ok(ImageDifference::None)) { + return None; + } + if self.ignore_left_missing + && !matches!(image_diff, Err(ref e) if e.is_left_missing()) + { + return None; } - if self.ignore_right_missing && !matches!(image_diff, Err(ref e) if e.is_right_missing()) { - return None + if self.ignore_right_missing + && !matches!(image_diff, Err(ref e) if e.is_right_missing()) + { + return None; } Some(PairResult { title: pair.title, @@ -66,7 +72,7 @@ impl DirDiffConfig { pub enum LeftRightError { Left(crate::Error), Right(crate::Error), - Both(crate::Error, crate::Error) + Both(crate::Error, crate::Error), } impl LeftRightError { @@ -84,19 +90,24 @@ impl LeftRightError { } pub fn is_left_missing(&self) -> bool { - self.left().map(|e| matches!(e, crate::Error::FileNotFound(_))).unwrap_or(false) + self.left() + .map(|e| matches!(e, crate::Error::FileNotFound(_))) + .unwrap_or(false) } pub fn is_right_missing(&self) -> bool { - self.right().map(|e| matches!(e, crate::Error::FileNotFound(_))).unwrap_or(false) + self.right() + .map(|e| matches!(e, crate::Error::FileNotFound(_))) + .unwrap_or(false) } } +#[derive(Debug)] pub struct PairResult { pub title: String, pub left: PathBuf, pub right: PathBuf, - pub image_diff: Result + pub image_diff: Result, } #[derive(Default)] @@ -110,14 +121,12 @@ impl DirDiff { } } - struct Pair { pub title: String, pub left: PathBuf, pub right: PathBuf, } - pub(crate) fn pairs_from_paths( left_path: &Path, right_path: &Path, @@ -145,7 +154,9 @@ pub(crate) fn pairs_from_paths( let left = left_path.join(&name); let right = right_path.join(&name); Pair { - title: name.into_string().unwrap_or_else( | name| name.to_string_lossy().into_owned()), + title: name + .into_string() + .unwrap_or_else(|name| name.to_string_lossy().into_owned()), left, right, } @@ -153,7 +164,6 @@ pub(crate) fn pairs_from_paths( .collect()) } - fn compute_pair_diff(pair: &Pair) -> Result { let left = load_image(&pair.left); let right = load_image(&pair.right); diff --git a/v2/kompari/src/imgdiff.rs b/v2/kompari/src/imgdiff.rs index 4560bf9..3a8422e 100644 --- a/v2/kompari/src/imgdiff.rs +++ b/v2/kompari/src/imgdiff.rs @@ -2,13 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use image::{Pixel, Rgba}; +use std::fmt::{Debug, Formatter}; use crate::Image; -#[derive(Debug)] pub enum ImageDifference { None, - SizeMismatch, + SizeMismatch { + left_size: (u32, u32), + right_size: (u32, u32), + }, Content { n_different_pixels: u64, distance_sum: u64, @@ -16,9 +19,34 @@ pub enum ImageDifference { }, } +impl Debug for ImageDifference { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ImageDifference::None => write!(f, "Difference::None"), + ImageDifference::SizeMismatch { + left_size, + right_size, + } => write!( + f, + "Difference::SizeMismatch({:?}, {:?})", + left_size, right_size + ), + ImageDifference::Content { + n_different_pixels, .. + } => f + .debug_struct("Difference::Content") + .field("n_different_pixels", n_different_pixels) + .finish(), + } + } +} + pub fn compare_images(left: &Image, right: &Image) -> ImageDifference { if left.width() != right.width() || left.height() != right.height() { - return ImageDifference::SizeMismatch; + return ImageDifference::SizeMismatch { + left_size: (left.width(), left.height()), + right_size: (right.width(), right.height()), + }; } let n_different_pixels: u64 = left diff --git a/v2/kompari/src/lib.rs b/v2/kompari/src/lib.rs index 2e1e8f8..0827964 100644 --- a/v2/kompari/src/lib.rs +++ b/v2/kompari/src/lib.rs @@ -16,13 +16,13 @@ // END LINEBENDER LINT SET #![cfg_attr(docsrs, feature(doc_auto_cfg))] -use std::path::PathBuf; pub use image; +use std::path::PathBuf; use thiserror::Error; -mod imgdiff; mod dirdiff; mod fsutils; +mod imgdiff; #[cfg(test)] mod tests; @@ -48,4 +48,6 @@ pub enum Error { GenericError(String), } -pub type Result = std::result::Result; \ No newline at end of file +pub type Result = std::result::Result; + +pub use imgdiff::{compare_images, ImageDifference}; diff --git a/v2/kompari/src/tests.rs b/v2/kompari/src/tests.rs index e2ea49a..0b710ce 100644 --- a/v2/kompari/src/tests.rs +++ b/v2/kompari/src/tests.rs @@ -1,20 +1,53 @@ // Copyright 2024 the Kompari Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +use crate::dirdiff::{DirDiffConfig, LeftRightError}; +use crate::imgdiff::ImageDifference; use std::path::Path; -use crate::dirdiff::DirDiffConfig; #[test] pub fn test_compare_dir() { let test_dir = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() - .parent().unwrap().join("tests"); + .parent() + .unwrap() + .join("tests"); let left = test_dir.join("left"); let right = test_dir.join("right"); let diff = DirDiffConfig::new(left, right).create_diff().unwrap(); let res = diff.results(); - assert_eq!(res.len(), 1); - -} \ No newline at end of file + let titles: Vec<_> = res.iter().map(|r| r.title.as_str()).collect(); + assert_eq!( + titles, + [ + "example1.png", + "left_missing.png", + "right_missing.png", + "size_error.png", + ] + ); + assert!(matches!( + res[0].image_diff, + Ok(ImageDifference::Content { + n_different_pixels: 275, + .. + }) + )); + assert!(matches!( + res[1].image_diff, + Err(LeftRightError::Left(crate::Error::FileNotFound(_))) + )); + assert!(matches!( + res[2].image_diff, + Err(LeftRightError::Right(crate::Error::FileNotFound(_))) + )); + assert!(matches!( + res[3].image_diff, + Ok(ImageDifference::SizeMismatch { + left_size: (850, 88), + right_size: (147, 881) + }) + )); +}