Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/polydiff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -85,6 +94,8 @@ def diff(
options = DiffOptions(
output_format=format,
output_path=output,
context_lines=context_lines,
similarity_threshold=similarity_threshold,
color=color,
)

Expand Down
1 change: 1 addition & 0 deletions src/polydiff/core/plugin_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 1 addition & 3 deletions src/polydiff/plugins/image_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import base64
import io
from pathlib import Path
from typing import Optional

from PIL import Image

Expand All @@ -13,7 +12,6 @@
compute_ssim,
create_diff_overlay,
create_side_by_side,
downsample_image,
)


Expand Down Expand Up @@ -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 = []
Expand Down
8 changes: 4 additions & 4 deletions src/polydiff/plugins/pdf_diff.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -266,7 +266,7 @@ def _build_html_output(
html_parts.extend([
' <details>',
' <summary>View text diff</summary>',
f' <pre>{page["text_diff"]}</pre>',
f' <pre>{escape(page["text_diff"])}</pre>',
' </details>',
])
html_parts.extend([
Expand Down
39 changes: 28 additions & 11 deletions src/polydiff/plugins/xlsx_diff.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -301,9 +306,15 @@ def _build_html_output(
]

if sheets_added:
html_parts.append(f'<div class="summary added"><strong>Sheets added:</strong> {", ".join(sheets_added)}</div>')
html_parts.append(
f'<div class="summary added"><strong>Sheets added:</strong> '
f'{", ".join(escape(name) for name in sheets_added)}</div>'
)
if sheets_removed:
html_parts.append(f'<div class="summary removed"><strong>Sheets removed:</strong> {", ".join(sheets_removed)}</div>')
html_parts.append(
f'<div class="summary removed"><strong>Sheets removed:</strong> '
f'{", ".join(escape(name) for name in sheets_removed)}</div>'
)

# Per-sheet tables
for sheet_name, diff in sheet_diffs.items():
Expand All @@ -312,17 +323,23 @@ def _build_html_output(
continue

html_parts.extend([
f'<h3>Sheet: {sheet_name}</h3>',
f'<h3>Sheet: {escape(sheet_name)}</h3>',
])

if diff["rows_added"]:
html_parts.append(f'<div class="summary added"><strong>Rows added:</strong> {len(diff["rows_added"])}</div>')
if diff["rows_removed"]:
html_parts.append(f'<div class="summary removed"><strong>Rows removed:</strong> {len(diff["rows_removed"])}</div>')
if diff["columns_added"]:
html_parts.append(f'<div class="summary added"><strong>Columns added:</strong> {", ".join(diff["columns_added"])}</div>')
html_parts.append(
f'<div class="summary added"><strong>Columns added:</strong> '
f'{", ".join(escape(col) for col in diff["columns_added"])}</div>'
)
if diff["columns_removed"]:
html_parts.append(f'<div class="summary removed"><strong>Columns removed:</strong> {", ".join(diff["columns_removed"])}</div>')
html_parts.append(
f'<div class="summary removed"><strong>Columns removed:</strong> '
f'{", ".join(escape(col) for col in diff["columns_removed"])}</div>'
)

if changes:
html_parts.extend([
Expand All @@ -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([
' <tr class="changed">',
f' <td>{change["cell"]}</td>',
f' <td>{escape(change["cell"])}</td>',
f' <td>{old_val}</td>',
f' <td>{new_val}</td>',
f' <td>{change["type"]}</td>',
f' <td>{escape(change["type"])}</td>',
' </tr>',
])

Expand Down
20 changes: 20 additions & 0 deletions tests/test_pdf_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<script>alert('xss')</script>",
}
],
pages_added=[],
pages_removed=[],
similarity=0.95,
summary="changed",
)

assert "<script>alert('xss')</script>" not in html
assert "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;" in html
31 changes: 31 additions & 0 deletions tests/test_xlsx_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
"<sheet>": {
"changes": [
{
"cell": "A1",
"old_value": "<b>old</b>",
"new_value": "<script>alert(1)</script>",
"type": "value",
}
],
"rows_added": [],
"rows_removed": [],
"columns_added": [],
"columns_removed": [],
}
},
sheets_added=["<new-sheet>"],
sheets_removed=[],
similarity=0.7,
summary="changed",
)

assert "<script>alert(1)</script>" not in html
assert "<b>old</b>" not in html
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
assert "&lt;b&gt;old&lt;/b&gt;" in html
assert "&lt;sheet&gt;" in html