diff --git a/README.md b/README.md
index 15ed08e..b4ea3b4 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,42 @@ Provides various entry points to run:
Used for regression testing in https://github.com/opendocument-app/OpenDocument.core.
+## Reviewing differences in the browser
+
+`compare-html-server` serves two pages:
+
+- the **overview** lists every comparable file with its status (`same`,
+ `different`, `pending`), a search box, status filters with counts, and an
+ `update ref` button per file plus `Update all in view`
+- the **compare page** (click a path) shows reference (A) and monitored (B)
+ side by side with synchronised scrolling, a diff strip in between, and the
+ controls to work through a review
+
+On the compare page:
+
+- **Update reference** copies the monitored file over the reference one; the
+ file is then marked `✓ accepted` for the rest of the session (the mark
+ disappears again if the monitored file changes afterwards)
+- **← / →** step to the previous and next document; tick *diffs only* to skip
+ files that already match
+- the **diff strip** maps the whole page into the column: red bars mark the
+ regions that differ, the blue box marks the visible part of the document, and
+ a click jumps both panes to that position
+
+| key | action |
+| --- | --- |
+| `←` / `k`, `→` / `j` | previous / next document |
+| `1` / `2` / `3` | reference only / side by side / monitored only |
+| `d` | toggle the diff strip |
+| `u` | update the reference |
+| `r` | reload both panes |
+| `Esc` | back to the overview |
+| `?` | shortcut help |
+
+Comparison results are computed live, so the pages update themselves while the
+monitored directory is being regenerated. Accepted marks are kept in memory
+only and are gone after a restart.
+
## Install via PyPI
```bash
diff --git a/pyproject.toml b/pyproject.toml
index 5c81be1..1e1a360 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,9 @@ download = "https://pypi.org/project/pyodr/#files"
tracker = "https://github.com/opendocument-app/compare-html/issues"
"release notes" = "https://github.com/opendocument-app/compare-html/releases"
+[tool.setuptools.package-data]
+htmlcmp = ["templates/*.html", "static/*.css", "static/*.js"]
+
[tool.pytest.ini_options]
pythonpath = ["src"]
diff --git a/src/htmlcmp/compare_output_server.py b/src/htmlcmp/compare_output_server.py
index e641807..fd3b72a 100755
--- a/src/htmlcmp/compare_output_server.py
+++ b/src/htmlcmp/compare_output_server.py
@@ -3,18 +3,22 @@
import io
import sys
+import time
import shutil
import argparse
import logging
import threading
import functools
+import collections
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
-from flask import Flask, send_from_directory, send_file
+from flask import Flask, Response, render_template, send_from_directory, url_for
import watchdog.observers
import watchdog.events
+from PIL import Image
+
from htmlcmp.common import (
comparable_file,
compare_files,
@@ -166,17 +170,24 @@ def compare(self, path: Path) -> None:
if not isinstance(path, Path):
raise TypeError("Path must be of type Path")
- if path not in self._future:
- raise RuntimeError("Path not submitted for comparison")
browser = getattr(Config.thread_local, "browser", None)
- result = compare_files(
- Config.path_a / path,
- Config.path_b / path,
- browser=browser,
- )
+ try:
+ result = compare_files(
+ Config.path_a / path,
+ Config.path_b / path,
+ browser=browser,
+ )
+ except Exception:
+ # A file may have vanished or failed to render between submission
+ # and comparison; report it as different rather than leaving the
+ # path stuck on "pending" forever.
+ logger.exception(f"Comparison failed for path: {path}")
+ result = False
self._result[path] = "same" if result else "different"
- self._future.pop(path)
+ # The worker can start before ``submit`` stored the future, so the
+ # entry is not guaranteed to be there yet.
+ self._future.pop(path, None)
def result(self, path: Path) -> str | None:
logger.debug(f"Getting comparison result for path: {path}")
@@ -257,7 +268,179 @@ def forget(self, path: Path) -> None:
self._result.pop(path, None)
-app = Flask("compare")
+class Accepted:
+ """Remembers which files had their reference updated during this session.
+
+ That is what "accepted" means on the pages: someone looked at the diff and
+ promoted the monitored file to be the new reference. The mark is dropped
+ again as soon as the monitored file changes after the update, so it never
+ claims more than it knows. State is in-memory only.
+ """
+
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._marks: dict[str, float] = {}
+
+ def mark(self, path: str) -> None:
+ logger.debug(f"Marking as accepted: {path}")
+
+ with self._lock:
+ self._marks[path] = time.time()
+
+ def check(self, path: str) -> bool:
+ with self._lock:
+ timestamp = self._marks.get(path)
+
+ if timestamp is None:
+ return False
+
+ try:
+ stale = (Config.path_b / path).stat().st_mtime > timestamp
+ except OSError:
+ stale = True
+
+ if stale:
+ logger.debug(f"Dropping stale acceptance: {path}")
+ with self._lock:
+ self._marks.pop(path, None)
+ return False
+
+ return True
+
+
+def highlight(diff: Image.Image) -> Image.Image:
+ """Turn a raw difference image into something readable at a glance.
+
+ Pixel-wise differences are mostly very dark, which is unusable as an image,
+ so every differing pixel is painted in full-strength red on a near-black
+ background.
+ """
+ mask = diff.convert("L").point(lambda value: 255 if value > 0 else 0)
+ visual = Image.new("RGB", diff.size, (12, 14, 18))
+ visual.paste((255, 76, 76), mask=mask)
+ return visual
+
+
+def diff_regions(diff: Image.Image, bands: int = 256) -> list[list[float]]:
+ """Locate the vertical bands of the page that contain differences.
+
+ Returned as [start, end] fractions of the page height. The compare page
+ draws these as overlay bars: a few differing pixels vanish when the diff
+ image itself is scaled into a narrow strip, whereas a band always stays
+ visible. Costs one scan over the image.
+ """
+ width, height = diff.size
+ band = max(1, height // bands)
+
+ regions = []
+ for top in range(0, height, band):
+ bottom = min(height, top + band)
+ if diff.crop((0, top, width, bottom)).getbbox() is None:
+ continue
+ if regions and regions[-1][1] == top:
+ regions[-1][1] = bottom
+ else:
+ regions.append([top, bottom])
+
+ return [[top / height, bottom / height] for top, bottom in regions]
+
+
+class DiffCache:
+ """Caches rendered diff images, keyed by the mtimes of both inputs.
+
+ Rendering drives a real browser and is by far the most expensive thing the
+ server does. The compare page wants both the image and its statistics, and
+ asks again on every reload, so without a cache a single review step would
+ pay for several full-page renders.
+ """
+
+ MAX_ENTRIES = 32
+
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._entries = collections.OrderedDict()
+
+ def get(self, path: str) -> tuple[bytes, dict]:
+ a = Config.path_a / path
+ b = Config.path_b / path
+ key = (path, a.stat().st_mtime_ns, b.stat().st_mtime_ns)
+
+ with self._lock:
+ hit = self._entries.get(key)
+ if hit is not None:
+ self._entries.move_to_end(key)
+ logger.debug(f"Diff cache hit: {path}")
+ return hit
+
+ logger.debug(f"Rendering diff: {path}")
+ with Config.browser_lock:
+ diff, _ = html_render_diff(a, b, Config.browser)
+
+ width, height = diff.size
+ bbox = diff.getbbox()
+ stats = {
+ "available": True,
+ "identical": bbox is None,
+ "width": width,
+ "height": height,
+ }
+ if bbox is not None:
+ left, top, right, bottom = bbox
+ stats["first_diff"] = top / height
+ stats["area"] = ((right - left) * (bottom - top)) / (width * height)
+ stats["regions"] = diff_regions(diff)
+
+ buffer = io.BytesIO()
+ highlight(diff).save(buffer, "PNG", optimize=True)
+ result = (buffer.getvalue(), stats)
+
+ with self._lock:
+ self._entries[key] = result
+ while len(self._entries) > self.MAX_ENTRIES:
+ self._entries.popitem(last=False)
+
+ return result
+
+
+app = Flask(__name__)
+
+accepted = Accepted()
+diff_cache = DiffCache()
+
+
+@app.context_processor
+def template_helpers() -> dict:
+ def static_url(filename: str) -> str:
+ """URL for a static asset, tagged with its modification time.
+
+ Keeps a page and its scripts in lockstep: a browser holding on to an
+ older stylesheet or script after an upgrade would otherwise render a
+ page whose markup no longer matches.
+ """
+ try:
+ stamp = int((Path(app.static_folder) / filename).stat().st_mtime)
+ except OSError:
+ stamp = 0
+ return url_for("static", filename=filename, v=stamp)
+
+ return {"static_url": static_url}
+
+
+@app.after_request
+def no_store_html(response: Response) -> Response:
+ # The listings are generated per request and go stale immediately.
+ if response.mimetype == "text/html":
+ response.headers["Cache-Control"] = "no-store"
+ return response
+
+
+def resolve_in(root: Path, path: str) -> Path | None:
+ """Resolve ``path`` below ``root``, or None if it would escape it."""
+ candidate = (root / path).resolve()
+ if not candidate.is_relative_to(root.resolve()):
+ logger.warning(f"Rejecting path outside of {root}: {path}")
+ return None
+ return candidate
def collect_entries() -> list[dict]:
@@ -270,6 +453,15 @@ def collect_entries() -> list[dict]:
"""
has_comparator = Config.comparator is not None
+ def entry(path: Path, message: str, result: str | None) -> dict:
+ key = str(path)
+ return {
+ "path": key,
+ "message": message,
+ "result": result,
+ "accepted": accepted.check(key),
+ }
+
def collect_one_sided(existing: Path, root: Path, message: str) -> list[dict]:
"""Walk a directory present on only one side.
@@ -283,14 +475,7 @@ def collect_one_sided(existing: Path, root: Path, message: str) -> list[dict]:
if child.is_dir():
entries.extend(collect_one_sided(child, root, message))
elif child.is_file() and comparable_file(child):
- entries.append(
- {
- "path": str(child.relative_to(root)),
- "comparable": True,
- "message": message,
- "result": "different",
- }
- )
+ entries.append(entry(child.relative_to(root), message, "different"))
return entries
@@ -315,32 +500,11 @@ def collect(a: Path, b: Path) -> list[dict]:
rel = common_path / name
if name in left_files and name in right_files:
result = Config.comparator.result(rel) if has_comparator else None
- entries.append(
- {
- "path": str(rel),
- "comparable": True,
- "message": "",
- "result": result,
- }
- )
+ entries.append(entry(rel, "", result))
elif name in right_files:
- entries.append(
- {
- "path": str(rel),
- "comparable": True,
- "message": "missing in reference (A)",
- "result": "different",
- }
- )
+ entries.append(entry(rel, "missing in reference (A)", "different"))
else:
- entries.append(
- {
- "path": str(rel),
- "comparable": True,
- "message": "missing in monitored (B)",
- "result": "different",
- }
- )
+ entries.append(entry(rel, "missing in monitored (B)", "different"))
for name in sorted(left_dirs ^ right_dirs):
if name in left_dirs:
@@ -366,296 +530,57 @@ def collect(a: Path, b: Path) -> list[dict]:
return entries
-@app.route("/script.js")
-def script_js():
- logger.debug("Serving script.js")
-
- return r"""
-function updateRef(path) {
- fetch(`/update_ref/${path}`)
- .then(response => {
- if (response.ok) {
- alert(`Reference updated for ${path}`);
- location.reload();
- } else {
- alert(`Failed to update reference for ${path}: ${response.statusText}`);
- }
- })
- .catch(error => {
- alert(`Error updating reference for ${path}: ${error}`);
- });
-}
-"""
-
-
@app.route("/")
def root():
logger.debug("Generating root directory listing")
- has_comparator = Config.comparator is not None
- entries = collect_entries()
-
- def badge(result: str | None) -> str:
- if result is None:
- return ""
- return f'{result}'
-
- rows = []
- for e in entries:
- path = e["path"]
- if e["comparable"]:
- name_cell = f'{path}'
- else:
- name_cell = f"{path}"
- rows.append(
- f'
'
- f'
{badge(e["result"])}
'
- f'
{name_cell}
'
- f'
{e["message"]}
'
- f'
'
- f"
"
- )
-
- log_link = ""
- if Config.log_file is not None:
- log_link = f'log file'
-
- head = r"""
-
-
-
-compare-html
-
-
-
-
-"""
-
- header = rf"""
-
compare-html
-
-
reference (A): {Config.path_a}
-
monitored (B): {Config.path_b}
-
-
-
-
- 0 total
- 0 same
- 0 diff
- 0 pending
-
-
-
-
-
-
-
-
- {log_link}
-
-
-"""
-
- table = (
- "
"
- "
Status
Path
Message
Actions
"
- "
" + "".join(rows) + "
"
+ return render_template(
+ "index.html",
+ entries=collect_entries(),
+ live=Config.comparator is not None,
+ path_a=Config.path_a,
+ path_b=Config.path_b,
+ log_file=Config.log_file,
)
- script = (
- r"""
-
-
-
-"""
+
+@app.route("/compare/")
+def compare(path: str):
+ logger.debug(f"Generating comparison page for path: {path}")
+
+ a = resolve_in(Config.path_a, path)
+ b = resolve_in(Config.path_b, path)
+ if a is None or b is None:
+ return "Invalid path", 400
+
+ return render_template(
+ "compare.html",
+ path=path,
+ file_a=Config.path_a / path,
+ file_b=Config.path_b / path,
+ live=Config.comparator is not None,
+ diff_available=Config.driver is not None and a.is_file() and b.is_file(),
)
- return head + header + table + script
+
+@app.route("/api/entries")
+def api_entries():
+ logger.debug("Serving entries")
+
+ return {
+ "live": Config.comparator is not None,
+ "entries": collect_entries(),
+ }
@app.route("/status")
def status():
+ """Legacy status endpoint: a flat path -> result mapping."""
logger.debug("Serving comparison status")
if Config.comparator is None:
return {}
- # Walk the filesystem the same way the page does so a poll reflects the
- # current state (including files created/deleted after startup), rather
- # than a cache the watchdog may not have kept current.
return {e["path"]: e["result"] for e in collect_entries()}
@@ -669,110 +594,78 @@ def logfile():
return send_from_directory(Config.log_file.parent, Config.log_file.name)
-@app.route("/compare/")
-def compare(path: str):
- logger.debug(f"Generating comparison page for path: {path}")
+def render_diff(path: str) -> tuple[bytes | None, dict, int]:
+ """Render (or fetch from cache) the diff for ``path``.
+
+ Returns the PNG bytes, the statistics and an HTTP status code; the bytes
+ are None when no diff could be produced.
+ """
+ if Config.driver is None:
+ return None, {"available": False, "error": "no browser driver"}, 404
+
+ a = resolve_in(Config.path_a, path)
+ b = resolve_in(Config.path_b, path)
+ if a is None or b is None:
+ return None, {"available": False, "error": "invalid path"}, 400
+ if not a.is_file() or not b.is_file():
+ return None, {"available": False, "error": "file missing on one side"}, 404
- if not isinstance(path, str):
- raise TypeError("Path must be a string")
-
- return rf"""
-
-
-
-
-
-
-