HTMLCompare 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmlcompare/__init__.py +16 -0
- htmlcompare/cli.py +24 -0
- htmlcompare/compare.py +325 -0
- htmlcompare/compare_css.py +163 -0
- htmlcompare/elements.py +69 -0
- htmlcompare/nodes.py +78 -0
- htmlcompare/normalize.py +173 -0
- htmlcompare/options.py +14 -0
- htmlcompare/parser.py +110 -0
- htmlcompare/result.py +57 -0
- htmlcompare/tests/__init__.py +0 -0
- htmlcompare/tests/compare_test.py +647 -0
- htmlcompare/tests/parser_test.py +247 -0
- htmlcompare/testutils.py +45 -0
- htmlcompare-0.4.0.data/scripts/htmlcompare +6 -0
- htmlcompare-0.4.0.dist-info/METADATA +88 -0
- htmlcompare-0.4.0.dist-info/RECORD +21 -0
- htmlcompare-0.4.0.dist-info/WHEEL +5 -0
- htmlcompare-0.4.0.dist-info/licenses/LICENSE.txt +19 -0
- htmlcompare-0.4.0.dist-info/top_level.txt +1 -0
- htmlcompare-0.4.0.dist-info/zip-safe +1 -0
htmlcompare/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from htmlcompare.compare import Difference, compare_html
|
|
4
|
+
from htmlcompare.options import CompareOptions
|
|
5
|
+
from htmlcompare.result import ComparisonResult
|
|
6
|
+
from htmlcompare.testutils import assert_different_html, assert_same_html
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'compare_html',
|
|
11
|
+
'Difference',
|
|
12
|
+
'CompareOptions',
|
|
13
|
+
'ComparisonResult',
|
|
14
|
+
'assert_different_html',
|
|
15
|
+
'assert_same_html',
|
|
16
|
+
]
|
htmlcompare/cli.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .testutils import assert_same_html
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = []
|
|
9
|
+
|
|
10
|
+
def htmlcompare_cli():
|
|
11
|
+
if len(sys.argv) < 3:
|
|
12
|
+
sys.stderr.write('usage: htmlcompare <EXPECTED> <ACTUAL>\n')
|
|
13
|
+
sys.exit(1)
|
|
14
|
+
expected_fn, actual_fn = sys.argv[1:3]
|
|
15
|
+
with open(expected_fn, 'rb') as expected_fp:
|
|
16
|
+
expected_html = expected_fp.read().decode('utf8')
|
|
17
|
+
with open(actual_fn, 'rb') as actual_fp:
|
|
18
|
+
actual_html = actual_fp.read().decode('utf8')
|
|
19
|
+
try:
|
|
20
|
+
assert_same_html(expected_html, actual_html, verbose=True)
|
|
21
|
+
except AssertionError:
|
|
22
|
+
raise
|
|
23
|
+
sys.exit(10)
|
|
24
|
+
print('HTML in both files is the same. :-)')
|
htmlcompare/compare.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from htmlcompare.compare_css import compare_css, compare_stylesheet
|
|
7
|
+
from htmlcompare.nodes import Comment, ConditionalComment, Document, Element, Node, TextNode
|
|
8
|
+
from htmlcompare.normalize import normalize_tree
|
|
9
|
+
from htmlcompare.options import CompareOptions
|
|
10
|
+
from htmlcompare.parser import parse_html
|
|
11
|
+
from htmlcompare.result import ComparisonResult, Difference, DifferenceType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ['compare_html']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compare_html(
|
|
18
|
+
expected_html: str,
|
|
19
|
+
actual_html: str,
|
|
20
|
+
options: Optional[CompareOptions] = None,
|
|
21
|
+
) -> ComparisonResult:
|
|
22
|
+
"""
|
|
23
|
+
Compare two HTML strings for equality.
|
|
24
|
+
|
|
25
|
+
This implementation uses a tree-based approach with normalization
|
|
26
|
+
to handle insignificant whitespace between block elements.
|
|
27
|
+
"""
|
|
28
|
+
expected_tree = parse_html(expected_html)
|
|
29
|
+
actual_tree = parse_html(actual_html)
|
|
30
|
+
|
|
31
|
+
# normalize trees to remove insignificant whitespace
|
|
32
|
+
expected_normalized = normalize_tree(expected_tree, options)
|
|
33
|
+
actual_normalized = normalize_tree(actual_tree, options)
|
|
34
|
+
return _compare_trees(expected_normalized, actual_normalized)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compare_trees(expected: Document, actual: Document) -> ComparisonResult:
|
|
38
|
+
differences: list[Difference] = []
|
|
39
|
+
_compare_node_lists(expected.children, actual.children, "", differences, parent_tag=None)
|
|
40
|
+
_documents_are_equal = (len(differences) == 0)
|
|
41
|
+
return ComparisonResult(is_equal=_documents_are_equal, differences=differences)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compare_node_lists(
|
|
45
|
+
expected: Sequence[Node],
|
|
46
|
+
actual: Sequence[Node],
|
|
47
|
+
path: str,
|
|
48
|
+
differences: list[Difference],
|
|
49
|
+
*,
|
|
50
|
+
parent_tag: Optional[str] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
max_len = max(len(expected), len(actual))
|
|
53
|
+
|
|
54
|
+
for i in range(max_len):
|
|
55
|
+
child_path = f"{path}[{i}]" if path else f"[{i}]"
|
|
56
|
+
|
|
57
|
+
if i >= len(expected):
|
|
58
|
+
# Extra node in actual
|
|
59
|
+
actual_node = actual[i]
|
|
60
|
+
differences.append(Difference(
|
|
61
|
+
type=DifferenceType.CHILD_EXTRA,
|
|
62
|
+
path=child_path,
|
|
63
|
+
expected=None,
|
|
64
|
+
actual=_node_summary(actual_node),
|
|
65
|
+
message=f"unexpected node: {_node_summary(actual_node)}",
|
|
66
|
+
))
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if i >= len(actual):
|
|
70
|
+
# Missing node in actual
|
|
71
|
+
expected_node = expected[i]
|
|
72
|
+
differences.append(Difference(
|
|
73
|
+
type=DifferenceType.CHILD_MISSING,
|
|
74
|
+
path=child_path,
|
|
75
|
+
expected=_node_summary(expected_node),
|
|
76
|
+
actual=None,
|
|
77
|
+
message=f"missing node: {_node_summary(expected_node)}",
|
|
78
|
+
))
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
_compare_nodes(expected[i], actual[i], child_path, differences, parent_tag=parent_tag)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _compare_nodes(
|
|
85
|
+
expected: Node,
|
|
86
|
+
actual: Node,
|
|
87
|
+
path: str,
|
|
88
|
+
differences: list[Difference],
|
|
89
|
+
*,
|
|
90
|
+
parent_tag: Optional[str] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
if type(expected) is not type(actual):
|
|
93
|
+
differences.append(Difference(
|
|
94
|
+
type=DifferenceType.NODE_TYPE_MISMATCH,
|
|
95
|
+
path=path,
|
|
96
|
+
expected=type(expected).__name__,
|
|
97
|
+
actual=type(actual).__name__,
|
|
98
|
+
))
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if isinstance(expected, Element):
|
|
102
|
+
assert isinstance(actual, Element)
|
|
103
|
+
_compare_elements(expected, actual, path, differences)
|
|
104
|
+
elif isinstance(expected, TextNode):
|
|
105
|
+
assert isinstance(actual, TextNode)
|
|
106
|
+
_compare_text_nodes(expected, actual, path, differences, parent_tag=parent_tag)
|
|
107
|
+
elif isinstance(expected, Comment):
|
|
108
|
+
assert isinstance(actual, Comment)
|
|
109
|
+
_compare_comments(expected, actual, path, differences)
|
|
110
|
+
elif isinstance(expected, ConditionalComment):
|
|
111
|
+
assert isinstance(actual, ConditionalComment)
|
|
112
|
+
_compare_conditional_comments(expected, actual, path, differences)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _compare_elements(
|
|
116
|
+
expected: Element,
|
|
117
|
+
actual: Element,
|
|
118
|
+
path: str,
|
|
119
|
+
differences: list[Difference],
|
|
120
|
+
) -> None:
|
|
121
|
+
element_path = f"{path} > {expected.tag}" if path else expected.tag
|
|
122
|
+
|
|
123
|
+
# check tag names
|
|
124
|
+
if expected.tag != actual.tag:
|
|
125
|
+
differences.append(Difference(
|
|
126
|
+
type=DifferenceType.TAG_MISMATCH,
|
|
127
|
+
path=element_path,
|
|
128
|
+
expected=expected.tag,
|
|
129
|
+
actual=actual.tag,
|
|
130
|
+
))
|
|
131
|
+
return # don't compare children if tags differ
|
|
132
|
+
|
|
133
|
+
_compare_attributes(expected.attributes, actual.attributes, element_path, differences)
|
|
134
|
+
# compare children, passing tag name for context-aware comparison (e.g., CSS in <style> tags)
|
|
135
|
+
_compare_node_lists(
|
|
136
|
+
expected.children,
|
|
137
|
+
actual.children,
|
|
138
|
+
element_path,
|
|
139
|
+
differences,
|
|
140
|
+
parent_tag=expected.tag,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _compare_attributes(
|
|
145
|
+
expected: dict[str, str],
|
|
146
|
+
actual: dict[str, str],
|
|
147
|
+
path: str,
|
|
148
|
+
differences: list[Difference],
|
|
149
|
+
) -> None:
|
|
150
|
+
expected_normalized = _normalize_attributes(expected)
|
|
151
|
+
actual_normalized = _normalize_attributes(actual)
|
|
152
|
+
all_keys = set(expected_normalized) | set(actual_normalized)
|
|
153
|
+
for key in sorted(all_keys):
|
|
154
|
+
if key not in expected_normalized:
|
|
155
|
+
differences.append(Difference(
|
|
156
|
+
type=DifferenceType.ATTRIBUTE_EXTRA,
|
|
157
|
+
path=f"{path}@{key}",
|
|
158
|
+
expected=None,
|
|
159
|
+
actual=actual_normalized[key],
|
|
160
|
+
message=f"unexpected attribute '{key}'",
|
|
161
|
+
))
|
|
162
|
+
elif key not in actual_normalized:
|
|
163
|
+
differences.append(Difference(
|
|
164
|
+
type=DifferenceType.ATTRIBUTE_MISSING,
|
|
165
|
+
path=f"{path}@{key}",
|
|
166
|
+
expected=expected_normalized[key],
|
|
167
|
+
actual=None,
|
|
168
|
+
message=f"missing attribute '{key}'",
|
|
169
|
+
))
|
|
170
|
+
elif key == 'class':
|
|
171
|
+
_compare_class_attribute(
|
|
172
|
+
expected_normalized[key], actual_normalized[key], path, differences
|
|
173
|
+
)
|
|
174
|
+
elif key == 'style':
|
|
175
|
+
_compare_style_attribute(
|
|
176
|
+
expected_normalized[key], actual_normalized[key], path, differences
|
|
177
|
+
)
|
|
178
|
+
elif expected_normalized[key] != actual_normalized[key]:
|
|
179
|
+
differences.append(Difference(
|
|
180
|
+
type=DifferenceType.ATTRIBUTE_MISMATCH,
|
|
181
|
+
path=f"{path}@{key}",
|
|
182
|
+
expected=expected_normalized[key],
|
|
183
|
+
actual=actual_normalized[key],
|
|
184
|
+
))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _normalize_attributes(attrs: dict[str, str]) -> dict[str, str]:
|
|
188
|
+
"""Normalize attributes, removing empty class/style attributes."""
|
|
189
|
+
result = {}
|
|
190
|
+
for key, value in attrs.items():
|
|
191
|
+
# an empty class attribute is same as absent
|
|
192
|
+
if key == 'class' and not value.strip():
|
|
193
|
+
continue
|
|
194
|
+
# an empty style attribute is same as absent
|
|
195
|
+
if key == 'style' and not value.strip():
|
|
196
|
+
continue
|
|
197
|
+
result[key] = value
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _compare_class_attribute(
|
|
202
|
+
expected: str,
|
|
203
|
+
actual: str,
|
|
204
|
+
path: str,
|
|
205
|
+
differences: list[Difference],
|
|
206
|
+
) -> None:
|
|
207
|
+
expected_classes = set(expected.split())
|
|
208
|
+
actual_classes = set(actual.split())
|
|
209
|
+
|
|
210
|
+
missing = expected_classes - actual_classes
|
|
211
|
+
extra = actual_classes - expected_classes
|
|
212
|
+
|
|
213
|
+
if missing:
|
|
214
|
+
differences.append(Difference(
|
|
215
|
+
type=DifferenceType.CLASS_MISSING,
|
|
216
|
+
path=f"{path}@class",
|
|
217
|
+
expected=sorted(missing),
|
|
218
|
+
actual=None,
|
|
219
|
+
message=f"missing classes: {sorted(missing)}",
|
|
220
|
+
))
|
|
221
|
+
if extra:
|
|
222
|
+
differences.append(Difference(
|
|
223
|
+
type=DifferenceType.CLASS_EXTRA,
|
|
224
|
+
path=f"{path}@class",
|
|
225
|
+
expected=None,
|
|
226
|
+
actual=sorted(extra),
|
|
227
|
+
message=f"unexpected classes: {sorted(extra)}",
|
|
228
|
+
))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _compare_style_attribute(
|
|
232
|
+
expected: str,
|
|
233
|
+
actual: str,
|
|
234
|
+
path: str,
|
|
235
|
+
differences: list[Difference],
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Compare style attributes using CSS-aware comparison."""
|
|
238
|
+
if compare_css(expected, actual):
|
|
239
|
+
return # styles are equivalent
|
|
240
|
+
|
|
241
|
+
differences.append(Difference(
|
|
242
|
+
type=DifferenceType.STYLE_MISMATCH,
|
|
243
|
+
path=f"{path}@style",
|
|
244
|
+
expected=expected,
|
|
245
|
+
actual=actual,
|
|
246
|
+
))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _compare_text_nodes(
|
|
250
|
+
expected: TextNode,
|
|
251
|
+
actual: TextNode,
|
|
252
|
+
path: str,
|
|
253
|
+
differences: list[Difference],
|
|
254
|
+
*,
|
|
255
|
+
parent_tag: Optional[str] = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
if parent_tag == 'style':
|
|
258
|
+
if compare_stylesheet(expected.content, actual.content):
|
|
259
|
+
return # CSS is semantically equivalent
|
|
260
|
+
differences.append(Difference(
|
|
261
|
+
type=DifferenceType.TEXT_MISMATCH,
|
|
262
|
+
path=path,
|
|
263
|
+
expected=expected.content,
|
|
264
|
+
actual=actual.content,
|
|
265
|
+
))
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if expected.content != actual.content:
|
|
269
|
+
differences.append(Difference(
|
|
270
|
+
type=DifferenceType.TEXT_MISMATCH,
|
|
271
|
+
path=path,
|
|
272
|
+
expected=expected.content,
|
|
273
|
+
actual=actual.content,
|
|
274
|
+
))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _compare_comments(
|
|
278
|
+
expected: Comment,
|
|
279
|
+
actual: Comment,
|
|
280
|
+
path: str,
|
|
281
|
+
differences: list[Difference],
|
|
282
|
+
) -> None:
|
|
283
|
+
if expected.content != actual.content:
|
|
284
|
+
differences.append(Difference(
|
|
285
|
+
type=DifferenceType.COMMENT_MISMATCH,
|
|
286
|
+
path=path,
|
|
287
|
+
expected=expected.content,
|
|
288
|
+
actual=actual.content,
|
|
289
|
+
))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _compare_conditional_comments(
|
|
293
|
+
expected: ConditionalComment,
|
|
294
|
+
actual: ConditionalComment,
|
|
295
|
+
path: str,
|
|
296
|
+
differences: list[Difference],
|
|
297
|
+
) -> None:
|
|
298
|
+
cc_path = f"{path} > <!--[if {expected.condition}]>" if path else f"<!--[if {expected.condition}]>" # noqa: E501
|
|
299
|
+
|
|
300
|
+
# Compare conditions
|
|
301
|
+
if expected.condition != actual.condition:
|
|
302
|
+
differences.append(Difference(
|
|
303
|
+
type=DifferenceType.CONDITIONAL_COMMENT_CONDITION_MISMATCH,
|
|
304
|
+
path=cc_path,
|
|
305
|
+
expected=expected.condition,
|
|
306
|
+
actual=actual.condition,
|
|
307
|
+
))
|
|
308
|
+
return # don't compare children if conditions differ
|
|
309
|
+
|
|
310
|
+
# Compare children
|
|
311
|
+
_compare_node_lists(expected.children, actual.children, cc_path, differences)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _node_summary(node: Node) -> str:
|
|
315
|
+
if isinstance(node, Element):
|
|
316
|
+
return f"<{node.tag}>"
|
|
317
|
+
elif isinstance(node, TextNode):
|
|
318
|
+
content = node.content[:20] + "..." if len(node.content) > 20 else node.content
|
|
319
|
+
return f"text({content!r})"
|
|
320
|
+
elif isinstance(node, Comment):
|
|
321
|
+
content = node.content[:20] + "..." if len(node.content) > 20 else node.content
|
|
322
|
+
return f"comment({content!r})"
|
|
323
|
+
elif isinstance(node, ConditionalComment):
|
|
324
|
+
return f"<!--[if {node.condition}]>..."
|
|
325
|
+
return str(type(node).__name__)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from operator import attrgetter
|
|
4
|
+
|
|
5
|
+
import tinycss2
|
|
6
|
+
from tinycss2.ast import AtRule, Declaration, NumberToken, QualifiedRule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = ['compare_css', 'compare_stylesheet']
|
|
10
|
+
|
|
11
|
+
def compare_css(expected_css, actual_css):
|
|
12
|
+
_e_css = normalize_css(expected_css)
|
|
13
|
+
_a_css = normalize_css(actual_css)
|
|
14
|
+
_e_css_str = tinycss2.serialize(_e_css)
|
|
15
|
+
_a_css_str = tinycss2.serialize(_a_css)
|
|
16
|
+
return _e_css_str == _a_css_str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compare_stylesheet(expected_css, actual_css):
|
|
20
|
+
_e_css_str = tinycss2.serialize(normalize_stylesheet(expected_css))
|
|
21
|
+
_a_css_str = tinycss2.serialize(normalize_stylesheet(actual_css))
|
|
22
|
+
return _e_css_str == _a_css_str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_dimension(token):
|
|
26
|
+
return (token.type == 'dimension')
|
|
27
|
+
|
|
28
|
+
def is_whitespace(token):
|
|
29
|
+
return (token.type == 'whitespace')
|
|
30
|
+
|
|
31
|
+
def _strip_whitespace(all_tokens):
|
|
32
|
+
tokens = []
|
|
33
|
+
for token in all_tokens:
|
|
34
|
+
if is_whitespace(token):
|
|
35
|
+
continue
|
|
36
|
+
tokens.append(token)
|
|
37
|
+
return tokens
|
|
38
|
+
|
|
39
|
+
def _strip_zero_units(all_tokens):
|
|
40
|
+
tokens = []
|
|
41
|
+
for token in all_tokens:
|
|
42
|
+
if is_dimension(token) and token.int_value == 0:
|
|
43
|
+
token = NumberToken(
|
|
44
|
+
token.source_line,
|
|
45
|
+
token.source_column,
|
|
46
|
+
token.value,
|
|
47
|
+
token.int_value,
|
|
48
|
+
token.representation,
|
|
49
|
+
)
|
|
50
|
+
tokens.append(token)
|
|
51
|
+
return tokens
|
|
52
|
+
|
|
53
|
+
def normalize_css(css_declaration_str):
|
|
54
|
+
_decls = []
|
|
55
|
+
_css_decls = tinycss2.parse_declaration_list(
|
|
56
|
+
css_declaration_str, skip_comments=True, skip_whitespace=True
|
|
57
|
+
)
|
|
58
|
+
for decl in _css_decls:
|
|
59
|
+
assert (decl.type == 'declaration'), decl
|
|
60
|
+
tokens = _strip_whitespace(decl.value)
|
|
61
|
+
tokens = _strip_zero_units(tokens)
|
|
62
|
+
_decl = Declaration(
|
|
63
|
+
line = decl.source_line,
|
|
64
|
+
column = decl.source_column,
|
|
65
|
+
name = decl.name,
|
|
66
|
+
lower_name = decl.lower_name,
|
|
67
|
+
value = tokens,
|
|
68
|
+
important = decl.important
|
|
69
|
+
)
|
|
70
|
+
_decls.append(_decl)
|
|
71
|
+
|
|
72
|
+
sorted_decls = sorted(_decls, key=attrgetter('name'))
|
|
73
|
+
return tuple(sorted_decls)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_stylesheet(css_str):
|
|
77
|
+
"""Normalize a CSS stylesheet (with selectors and rules) for comparison.
|
|
78
|
+
|
|
79
|
+
Unlike normalize_css() which handles declaration lists (for style attributes),
|
|
80
|
+
this function handles full stylesheets with selectors like:
|
|
81
|
+
body { margin: 0; }
|
|
82
|
+
.foo { color: red; }
|
|
83
|
+
@media screen { .foo { color: blue; } }
|
|
84
|
+
"""
|
|
85
|
+
rules = tinycss2.parse_stylesheet(css_str, skip_comments=True, skip_whitespace=True)
|
|
86
|
+
return _normalize_rule_list(rules)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_rule_list(rules):
|
|
90
|
+
"""Normalize a list of CSS rules (qualified rules, at-rules, etc.)."""
|
|
91
|
+
normalized_rules = []
|
|
92
|
+
|
|
93
|
+
for rule in rules:
|
|
94
|
+
if rule.type == 'qualified-rule':
|
|
95
|
+
normalized_rule = _normalize_qualified_rule(rule)
|
|
96
|
+
normalized_rules.append(normalized_rule)
|
|
97
|
+
elif rule.type == 'at-rule':
|
|
98
|
+
normalized_rule = _normalize_at_rule(rule)
|
|
99
|
+
normalized_rules.append(normalized_rule)
|
|
100
|
+
elif rule.type == 'error':
|
|
101
|
+
# keep errors for debugging
|
|
102
|
+
normalized_rules.append(rule)
|
|
103
|
+
|
|
104
|
+
return tuple(normalized_rules)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _normalize_qualified_rule(rule):
|
|
108
|
+
"""Normalize a qualified rule (selector { declarations })."""
|
|
109
|
+
prelude = _strip_whitespace(rule.prelude)
|
|
110
|
+
|
|
111
|
+
# parse and normalize the content (declarations)
|
|
112
|
+
content_decls = tinycss2.parse_declaration_list(
|
|
113
|
+
rule.content, skip_comments=True, skip_whitespace=True
|
|
114
|
+
)
|
|
115
|
+
normalized_decls = []
|
|
116
|
+
for decl in content_decls:
|
|
117
|
+
if decl.type == 'declaration':
|
|
118
|
+
tokens = _strip_whitespace(decl.value)
|
|
119
|
+
tokens = _strip_zero_units(tokens)
|
|
120
|
+
_decl = Declaration(
|
|
121
|
+
line = decl.source_line,
|
|
122
|
+
column = decl.source_column,
|
|
123
|
+
name = decl.name,
|
|
124
|
+
lower_name = decl.lower_name,
|
|
125
|
+
value = tokens,
|
|
126
|
+
important = decl.important
|
|
127
|
+
)
|
|
128
|
+
normalized_decls.append(_decl)
|
|
129
|
+
|
|
130
|
+
# sort declarations by name for order-independent comparison
|
|
131
|
+
sorted_decls = sorted(normalized_decls, key=attrgetter('name'))
|
|
132
|
+
|
|
133
|
+
return QualifiedRule(
|
|
134
|
+
rule.source_line,
|
|
135
|
+
rule.source_column,
|
|
136
|
+
prelude,
|
|
137
|
+
sorted_decls,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _normalize_at_rule(rule):
|
|
142
|
+
"""Normalize an at-rule (@media, @keyframes, etc.)."""
|
|
143
|
+
prelude = _strip_whitespace(rule.prelude)
|
|
144
|
+
|
|
145
|
+
# normalize the content if it contains nested rules (like @media)
|
|
146
|
+
if rule.content is not None:
|
|
147
|
+
content_rules = tinycss2.parse_rule_list(
|
|
148
|
+
rule.content,
|
|
149
|
+
skip_comments=True,
|
|
150
|
+
skip_whitespace=True,
|
|
151
|
+
)
|
|
152
|
+
normalized_content = list(_normalize_rule_list(content_rules))
|
|
153
|
+
else:
|
|
154
|
+
normalized_content = None
|
|
155
|
+
|
|
156
|
+
return AtRule(
|
|
157
|
+
rule.source_line,
|
|
158
|
+
rule.source_column,
|
|
159
|
+
rule.at_keyword,
|
|
160
|
+
rule.lower_at_keyword,
|
|
161
|
+
prelude,
|
|
162
|
+
normalized_content,
|
|
163
|
+
)
|
htmlcompare/elements.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
__all__ = ['is_block_element', 'is_inline_element', 'is_preformatted_element']
|
|
5
|
+
|
|
6
|
+
# Block-level elements where whitespace between them is typically insignificant.
|
|
7
|
+
# Based on HTML5 spec and browser rendering behavior.
|
|
8
|
+
BLOCK_ELEMENTS = frozenset({
|
|
9
|
+
# Document sections
|
|
10
|
+
'html', 'head', 'body',
|
|
11
|
+
# Content sectioning
|
|
12
|
+
'address', 'article', 'aside', 'footer', 'header', 'hgroup',
|
|
13
|
+
'main', 'nav', 'section',
|
|
14
|
+
# Text content (block)
|
|
15
|
+
'blockquote', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure',
|
|
16
|
+
'hr', 'li', 'menu', 'ol', 'p', 'pre', 'ul',
|
|
17
|
+
# Table content
|
|
18
|
+
'caption', 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot',
|
|
19
|
+
'th', 'thead', 'tr',
|
|
20
|
+
# Form elements (block-like)
|
|
21
|
+
'fieldset', 'form', 'legend', 'optgroup', 'option',
|
|
22
|
+
# Other block elements
|
|
23
|
+
'details', 'dialog', 'summary',
|
|
24
|
+
# Headings
|
|
25
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
26
|
+
# Metadata (typically not rendered)
|
|
27
|
+
'base', 'link', 'meta', 'noscript', 'script', 'style', 'template', 'title',
|
|
28
|
+
# VML elements (used in Outlook conditional comments)
|
|
29
|
+
'v:image', 'v:rect', 'v:fill', 'v:stroke', 'v:textbox', 'v:shape',
|
|
30
|
+
'v:shapetype', 'v:roundrect', 'v:oval', 'v:line', 'v:polyline',
|
|
31
|
+
'v:group', 'v:background',
|
|
32
|
+
# Microsoft Office elements
|
|
33
|
+
'o:p', 'o:wrapblock',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
# Inline elements where whitespace is significant for rendering.
|
|
37
|
+
INLINE_ELEMENTS = frozenset({
|
|
38
|
+
# Text semantics
|
|
39
|
+
'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data',
|
|
40
|
+
'dfn', 'em', 'i', 'kbd', 'mark', 'q', 'rp', 'rt', 'ruby',
|
|
41
|
+
's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time',
|
|
42
|
+
'u', 'var', 'wbr',
|
|
43
|
+
# Edits
|
|
44
|
+
'del', 'ins',
|
|
45
|
+
# Embedded content (inline by default)
|
|
46
|
+
'audio', 'canvas', 'embed', 'iframe', 'img', 'math', 'object',
|
|
47
|
+
'picture', 'svg', 'video',
|
|
48
|
+
# Form elements (inline by default)
|
|
49
|
+
'button', 'datalist', 'input', 'label', 'meter', 'output',
|
|
50
|
+
'progress', 'select', 'textarea',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
# Elements that preserve whitespace (like <pre>).
|
|
54
|
+
PREFORMATTED_ELEMENTS = frozenset({
|
|
55
|
+
'pre', 'code', 'textarea', 'script', 'style',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_block_element(tag: str) -> bool:
|
|
60
|
+
return tag.lower() in BLOCK_ELEMENTS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_inline_element(tag: str) -> bool:
|
|
64
|
+
return tag.lower() in INLINE_ELEMENTS
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_preformatted_element(tag: str) -> bool:
|
|
68
|
+
"""Return True if the element preserves whitespace."""
|
|
69
|
+
return tag.lower() in PREFORMATTED_ELEMENTS
|
htmlcompare/nodes.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ['Node', 'Element', 'TextNode', 'Comment', 'ConditionalComment', 'Document']
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TextNode:
|
|
13
|
+
"""Represents text content in HTML."""
|
|
14
|
+
content: str
|
|
15
|
+
|
|
16
|
+
def __eq__(self, other):
|
|
17
|
+
if not isinstance(other, TextNode):
|
|
18
|
+
return NotImplemented
|
|
19
|
+
return self.content == other.content
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Comment:
|
|
24
|
+
"""Represents an HTML comment."""
|
|
25
|
+
content: str
|
|
26
|
+
|
|
27
|
+
def __eq__(self, other):
|
|
28
|
+
if not isinstance(other, Comment):
|
|
29
|
+
return NotImplemented
|
|
30
|
+
return self.content == other.content
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ConditionalComment:
|
|
35
|
+
"""
|
|
36
|
+
Represents an IE conditional comment.
|
|
37
|
+
|
|
38
|
+
Example: <!--[if IE]><p>IE only</p><![endif]-->
|
|
39
|
+
"""
|
|
40
|
+
condition: str # e.g., "IE", "lt IE 9", "gte IE 8"
|
|
41
|
+
children: list['Node'] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
def __eq__(self, other):
|
|
44
|
+
if not isinstance(other, ConditionalComment):
|
|
45
|
+
return NotImplemented
|
|
46
|
+
return self.condition == other.condition and self.children == other.children
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Element:
|
|
51
|
+
"""Represents an HTML element with tag, attributes, and children."""
|
|
52
|
+
tag: str
|
|
53
|
+
attributes: dict[str, str] = field(default_factory=dict)
|
|
54
|
+
children: Sequence['Node'] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
def __eq__(self, other):
|
|
57
|
+
if not isinstance(other, Element):
|
|
58
|
+
return NotImplemented
|
|
59
|
+
return (
|
|
60
|
+
self.tag == other.tag
|
|
61
|
+
and self.attributes == other.attributes
|
|
62
|
+
and self.children == other.children
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Document:
|
|
68
|
+
"""Represents a parsed HTML document (list of root nodes)."""
|
|
69
|
+
children: list['Node'] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
def __eq__(self, other):
|
|
72
|
+
if not isinstance(other, Document):
|
|
73
|
+
return NotImplemented
|
|
74
|
+
return self.children == other.children
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Type alias for any node type
|
|
78
|
+
Node = Union[Element, TextNode, Comment, ConditionalComment]
|