From 4ca94199caf3fd479715141956e0417b690d8acf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:49:22 +0000 Subject: [PATCH] Harden HTML output and add advanced diff options Co-authored-by: teaphile <141439252+teaphile@users.noreply.github.com> --- src/polydiff/cli.py | 15 ++++++++++-- src/polydiff/core/plugin_base.py | 1 + src/polydiff/plugins/image_diff.py | 4 +-- src/polydiff/plugins/pdf_diff.py | 8 +++--- src/polydiff/plugins/xlsx_diff.py | 39 +++++++++++++++++++++--------- tests/test_pdf_diff.py | 20 +++++++++++++++ tests/test_xlsx_diff.py | 31 ++++++++++++++++++++++++ 7 files changed, 98 insertions(+), 20 deletions(-) diff --git a/src/polydiff/cli.py b/src/polydiff/cli.py index 5b9fc44..f7ff03d 100644 --- a/src/polydiff/cli.py +++ b/src/polydiff/cli.py @@ -13,8 +13,6 @@ from .core.git_integration import ( install_polydiff, parse_diff_driver_args, - setup_git_config, - write_gitattributes, ) from .core.plugin_base import DiffOptions from .core.registry import registry @@ -60,6 +58,17 @@ def diff( True, "--color/--no-color", help="Enable/disable colored output" ), + context_lines: int = typer.Option( + 3, "--context-lines", "-c", + min=0, + help="Context lines for text diffs (e.g., PDF text changes)" + ), + similarity_threshold: float = typer.Option( + 0.99, "--similarity-threshold", + min=0.0, + max=1.0, + help="Threshold below which files are considered changed" + ), ): """Compare two files directly (works with or without git).""" # Validate files exist @@ -85,6 +94,8 @@ def diff( options = DiffOptions( output_format=format, output_path=output, + context_lines=context_lines, + similarity_threshold=similarity_threshold, color=color, ) diff --git a/src/polydiff/core/plugin_base.py b/src/polydiff/core/plugin_base.py index 5d2577e..f4952df 100644 --- a/src/polydiff/core/plugin_base.py +++ b/src/polydiff/core/plugin_base.py @@ -13,6 +13,7 @@ class DiffOptions: output_format: str = "terminal" # "terminal" | "html" | "json" | "image" output_path: Optional[Path] = None context_lines: int = 3 # for text-layer diffs inside plugins (PDF text, etc.) + similarity_threshold: float = 0.99 # changed if similarity < threshold color: bool = True diff --git a/src/polydiff/plugins/image_diff.py b/src/polydiff/plugins/image_diff.py index 8b79a1e..30c1573 100644 --- a/src/polydiff/plugins/image_diff.py +++ b/src/polydiff/plugins/image_diff.py @@ -3,7 +3,6 @@ import base64 import io from pathlib import Path -from typing import Optional from PIL import Image @@ -13,7 +12,6 @@ compute_ssim, create_diff_overlay, create_side_by_side, - downsample_image, ) @@ -58,7 +56,7 @@ def diff(self, path_a: Path, path_b: Path, options: DiffOptions) -> DiffResult: side_by_side = create_side_by_side(image_a, image_b, diff_overlay) # Determine if changed - changed = similarity < 0.99 # Allow tiny floating point differences + changed = similarity < options.similarity_threshold # Build summary summary_parts = [] diff --git a/src/polydiff/plugins/pdf_diff.py b/src/polydiff/plugins/pdf_diff.py index 599b2bf..4d55211 100644 --- a/src/polydiff/plugins/pdf_diff.py +++ b/src/polydiff/plugins/pdf_diff.py @@ -1,14 +1,14 @@ """PDF diff plugin for polydiff.""" import difflib +from html import escape from pathlib import Path -from typing import Optional import fitz # PyMuPDF from PIL import Image from ..core.plugin_base import DiffOptions, DiffPlugin, DiffResult -from ..utils.image_ops import compute_ssim, downsample_image +from ..utils.image_ops import compute_ssim class PdfDiffPlugin(DiffPlugin): @@ -90,7 +90,7 @@ def diff(self, path_a: Path, path_b: Path, options: DiffOptions) -> DiffResult: else: avg_similarity = 0.0 - changed = avg_similarity < 0.99 or pages_added or pages_removed or any( + changed = avg_similarity < options.similarity_threshold or pages_added or pages_removed or any( p["text_changed"] for p in page_results ) @@ -266,7 +266,7 @@ def _build_html_output( html_parts.extend([ '
', ' View text diff', - f'
{page["text_diff"]}
', + f'
{escape(page["text_diff"])}
', '
', ]) html_parts.extend([ diff --git a/src/polydiff/plugins/xlsx_diff.py b/src/polydiff/plugins/xlsx_diff.py index 6abb0f2..8d10135 100644 --- a/src/polydiff/plugins/xlsx_diff.py +++ b/src/polydiff/plugins/xlsx_diff.py @@ -1,7 +1,7 @@ """XLSX diff plugin for polydiff.""" from pathlib import Path -from typing import Any, Optional +from html import escape from openpyxl import load_workbook from openpyxl.utils import get_column_letter @@ -72,7 +72,12 @@ def diff(self, path_a: Path, path_b: Path, options: DiffOptions) -> DiffResult: diff["rows_added"] or diff["rows_removed"] or diff["columns_added"] or diff["columns_removed"] for diff in sheet_diffs.values() ) - changed = similarity < 0.99 or bool(sheets_added) or bool(sheets_removed) or has_structural_changes + changed = ( + similarity < options.similarity_threshold + or bool(sheets_added) + or bool(sheets_removed) + or has_structural_changes + ) # Build summary summary_parts = [] @@ -301,9 +306,15 @@ def _build_html_output( ] if sheets_added: - html_parts.append(f'
Sheets added: {", ".join(sheets_added)}
') + html_parts.append( + f'
Sheets added: ' + f'{", ".join(escape(name) for name in sheets_added)}
' + ) if sheets_removed: - html_parts.append(f'
Sheets removed: {", ".join(sheets_removed)}
') + html_parts.append( + f'
Sheets removed: ' + f'{", ".join(escape(name) for name in sheets_removed)}
' + ) # Per-sheet tables for sheet_name, diff in sheet_diffs.items(): @@ -312,7 +323,7 @@ def _build_html_output( continue html_parts.extend([ - f'

Sheet: {sheet_name}

', + f'

Sheet: {escape(sheet_name)}

', ]) if diff["rows_added"]: @@ -320,9 +331,15 @@ def _build_html_output( if diff["rows_removed"]: html_parts.append(f'
Rows removed: {len(diff["rows_removed"])}
') if diff["columns_added"]: - html_parts.append(f'
Columns added: {", ".join(diff["columns_added"])}
') + html_parts.append( + f'
Columns added: ' + f'{", ".join(escape(col) for col in diff["columns_added"])}
' + ) if diff["columns_removed"]: - html_parts.append(f'
Columns removed: {", ".join(diff["columns_removed"])}
') + html_parts.append( + f'
Columns removed: ' + f'{", ".join(escape(col) for col in diff["columns_removed"])}
' + ) if changes: html_parts.extend([ @@ -339,14 +356,14 @@ def _build_html_output( ]) for change in changes[:100]: # Limit to 100 in HTML - old_val = str(change["old_value"]) if change["old_value"] is not None else "" - new_val = str(change["new_value"]) if change["new_value"] is not None else "" + old_val = escape(str(change["old_value"])) if change["old_value"] is not None else "" + new_val = escape(str(change["new_value"])) if change["new_value"] is not None else "" html_parts.extend([ ' ', - f' {change["cell"]}', + f' {escape(change["cell"])}', f' {old_val}', f' {new_val}', - f' {change["type"]}', + f' {escape(change["type"])}', ' ', ]) diff --git a/tests/test_pdf_diff.py b/tests/test_pdf_diff.py index 0cf455c..075aaf7 100644 --- a/tests/test_pdf_diff.py +++ b/tests/test_pdf_diff.py @@ -93,3 +93,23 @@ def test_extract_text(self, plugin): assert "Hello World" in text assert "test document" in text + + def test_html_output_escapes_text_diff(self, plugin): + """Test that HTML output escapes embedded text diff content.""" + html = plugin._build_html_output( + page_results=[ + { + "number": 1, + "visual_similarity": 0.95, + "text_changed": True, + "text_diff": "", + } + ], + pages_added=[], + pages_removed=[], + similarity=0.95, + summary="changed", + ) + + assert "" not in html + assert "<script>alert('xss')</script>" in html diff --git a/tests/test_xlsx_diff.py b/tests/test_xlsx_diff.py index b866344..78f96ee 100644 --- a/tests/test_xlsx_diff.py +++ b/tests/test_xlsx_diff.py @@ -106,3 +106,34 @@ def test_terminal_output_contains_sheet_info(self, plugin, options): ) assert "sheet" in result.terminal_output.lower() or "cell" in result.terminal_output.lower() + + def test_html_output_escapes_cell_values(self, plugin): + """Test that HTML output escapes sheet names and cell values.""" + html = plugin._build_html_output( + sheet_diffs={ + "": { + "changes": [ + { + "cell": "A1", + "old_value": "old", + "new_value": "", + "type": "value", + } + ], + "rows_added": [], + "rows_removed": [], + "columns_added": [], + "columns_removed": [], + } + }, + sheets_added=[""], + sheets_removed=[], + similarity=0.7, + summary="changed", + ) + + assert "" not in html + assert "old" not in html + assert "<script>alert(1)</script>" in html + assert "<b>old</b>" in html + assert "<sheet>" in html