feat(diff_report): render formatting_changes as a per-pair block

Adds a "🎨 Formatting changes" block to the per-page diff report
when the deterministic formatting layer finds typographic flips.
Distinguishes page-wide style shifts from local span flips, lists up
to three example quotes per aggregated finding, and HTML-escapes all
user-controlled strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-05-19 10:08:47 +02:00
parent 2b1bb9ccf0
commit 7eaac85df3
2 changed files with 106 additions and 0 deletions

View file

@ -75,6 +75,46 @@ def _render_diff_list(items: List[str], css_class: str, label: str, icon: str) -
"""
def _render_formatting_block(findings: List[Dict]) -> str:
if not findings:
return ''
def _fmt_value(v):
if isinstance(v, bool):
return 'Bold' if v else 'Regular'
return str(v)
items = []
for f in findings:
attr = f.get('attribute', '')
old_v = _fmt_value(f.get('old_value'))
new_v = _fmt_value(f.get('new_value'))
total = f.get('total_span_count', 0)
page_wide = f.get('page_wide', False)
quotes = f.get('example_quotes', []) or []
if page_wide:
prefix = f"<strong>Page-wide {html.escape(attr)} change</strong>: {html.escape(old_v)}{html.escape(new_v)}"
else:
prefix = f"<strong>{html.escape(attr).capitalize()}: {html.escape(old_v)}{html.escape(new_v)}</strong>"
quote_html = ''
if quotes:
quoted = ', '.join(f'&ldquo;{html.escape(q)}&rdquo;' for q in quotes[:3])
extra = total - len(quotes[:3])
extra_html = f" <span class='muted'>…and {extra} more</span>" if extra > 0 else ''
quote_html = f" ({total} span{'s' if total != 1 else ''}): {quoted}{extra_html}"
items.append(f"<li>{prefix}{quote_html}</li>")
return f"""
<div class='diff-block block-style'>
<div class='diff-label'>🎨 Formatting changes</div>
<ul>{''.join(items)}</ul>
</div>
"""
def _render_pair_card(entry: Dict, pair_diffs: Dict) -> str:
old = entry['old_page']
new = entry['new_page']
@ -132,6 +172,7 @@ def _render_pair_card(entry: Dict, pair_diffs: Dict) -> str:
blocks.append(_render_diff_list(pair.get('modified') or [], 'block-modified', 'Modified', ''))
blocks.append(_render_diff_list(pair.get('moved') or [], 'block-moved', 'Moved', ''))
blocks.append(_render_diff_list(pair.get('style_changes') or [], 'block-style', 'Style changes', '🎨'))
blocks.append(_render_formatting_block(pair.get('formatting_changes') or []))
error_block = ''
if pair.get('error'):

View file

@ -0,0 +1,65 @@
"""Smoke test for the new formatting-changes rendering block."""
from document_mode.diff_report_writer import _render_formatting_block
def test_empty_findings_render_nothing():
assert _render_formatting_block([]) == ''
def test_single_bold_flip_renders_with_quote():
findings = [{
'attribute': 'bold',
'old_value': True,
'new_value': False,
'example_quotes': ['Theft of personal belongings'],
'total_span_count': 1,
'page_wide': False,
}]
html_out = _render_formatting_block(findings)
assert '🎨 Formatting changes' in html_out
assert 'Theft of personal belongings' in html_out
assert 'Bold' in html_out
assert 'Regular' in html_out
assert 'block-style' in html_out
def test_page_wide_flag_changes_label():
findings = [{
'attribute': 'font',
'old_value': 'AXASans-Regular',
'new_value': 'Helvetica',
'example_quotes': ['Some body text'],
'total_span_count': 17,
'page_wide': True,
}]
html_out = _render_formatting_block(findings)
assert 'Page-wide font change' in html_out
def test_html_escape_in_quotes():
findings = [{
'attribute': 'bold',
'old_value': True,
'new_value': False,
'example_quotes': ['<script>alert("xss")</script>'],
'total_span_count': 1,
'page_wide': False,
}]
html_out = _render_formatting_block(findings)
assert '<script>' not in html_out
assert '&lt;script&gt;' in html_out
def test_aggregated_finding_shows_and_x_more():
findings = [{
'attribute': 'bold',
'old_value': True,
'new_value': False,
'example_quotes': ['First quote', 'Second quote', 'Third quote'],
'total_span_count': 12,
'page_wide': False,
}]
html_out = _render_formatting_block(findings)
assert '12 spans' in html_out
assert 'and 9 more' in html_out