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