Source code for dsw_document_template_tool.html_diff
"""HTML normalization and diff helpers used by regression checks."""
from __future__ import annotations
import difflib
import re
from bs4 import BeautifulSoup
from bs4.element import NavigableString, Tag
PRESERVE_WHITESPACE_TAGS = {"code", "pre", "script", "style"}
[docs]
def normalize_html(html: str, *, ignore_patterns: list[str] | None = None) -> str:
"""Normalize rendered HTML so meaningful regressions diff cleanly."""
soup = BeautifulSoup(html, "html.parser")
_sort_tag_attributes(soup)
_collapse_text_nodes(soup)
normalized = soup.decode(formatter="minimal")
normalized = re.sub(r">\s+<", "><", normalized)
normalized = normalized.strip()
return _remove_ignored_patterns(normalized, ignore_patterns or [])
def _sort_tag_attributes(soup: BeautifulSoup) -> None:
"""Sort attributes so equivalent HTML serializes consistently."""
for tag in soup.find_all(True):
if isinstance(tag, Tag):
tag.attrs = {
key: _stringify_attribute_value(tag.attrs[key]) for key in sorted(tag.attrs)
}
def _stringify_attribute_value(value: object) -> str | list[str]:
if isinstance(value, list):
return [str(item) for item in value]
return str(value)
def _collapse_text_nodes(soup: BeautifulSoup) -> None:
"""Collapse non-preformatted text nodes."""
for node in soup.find_all(string=True):
if not isinstance(node, NavigableString):
continue
parent_name = node.parent.name if node.parent is not None else None
if parent_name in PRESERVE_WHITESPACE_TAGS:
continue
collapsed = re.sub(r"\s+", " ", str(node)).strip()
node.replace_with(collapsed)
def _remove_ignored_patterns(normalized: str, ignore_patterns: list[str]) -> str:
for pattern in ignore_patterns:
normalized = re.sub(pattern, "", normalized, flags=re.MULTILINE)
return normalized
[docs]
def build_unified_diff(
baseline_text: str,
candidate_text: str,
*,
baseline_label: str = "baseline",
candidate_label: str = "candidate",
) -> str:
"""Build a unified diff string for normalized HTML."""
diff_lines = difflib.unified_diff(
baseline_text.splitlines(),
candidate_text.splitlines(),
fromfile=baseline_label,
tofile=candidate_label,
lineterm="",
)
return "\n".join(diff_lines)