diff --git a/src/zimscraperlib/rewriting/css.py b/src/zimscraperlib/rewriting/css.py index 507a2ca..df3739b 100644 --- a/src/zimscraperlib/rewriting/css.py +++ b/src/zimscraperlib/rewriting/css.py @@ -10,6 +10,8 @@ """ import re +import string +import unicodedata from collections.abc import Iterable, Sequence from functools import partial from typing import Any @@ -29,6 +31,42 @@ from zimscraperlib.rewriting.rx_replacer import RxRewriter, TransformationRule from zimscraperlib.rewriting.url_rewriting import ArticleUrlRewriter +_HEX = set(string.hexdigits) +_WS = set(" \t\n\r\f") + + +def _escape_string_value(value: str) -> str: + """Serialize a CSS string value while preserving non-printable characters.""" + out: list[str] = [] + for idx, char in enumerate(value): + if char == '"': + out.append('\\"') + elif char == "\\": + out.append("\\\\") + elif char == "\n": + out.append("\\A ") + elif char == "\r": + out.append("\\D ") + elif char == "\f": + out.append("\\C ") + elif unicodedata.category(char) in ("Cc", "Co", "Cs", "Cn"): + out.append(f"\\{ord(char):x}") + nxt = value[idx + 1 : idx + 2] + if nxt and (nxt in _HEX or nxt in _WS): + out.append(" ") + else: + out.append(char) + return "".join(out) + + +def _is_terminated_string(token: ast.StringToken) -> bool: + """Check whether a StringToken has a valid (unescaped) closing quote.""" + rep = token.representation + if len(rep) <= 1 or rep[-1] != rep[0]: + return False + num_backslashes = len(rep[:-1]) - len(rep[:-1].rstrip("\\")) + return num_backslashes % 2 == 0 + class FallbackRegexCssRewriter(RxRewriter): """ @@ -211,6 +249,9 @@ def _process_node(self, node: ast.Node): self._process_list( node.value # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] ) + elif isinstance(node, ast.StringToken): + if _is_terminated_string(node): + node.representation = f'"{_escape_string_value(node.value)}"' elif isinstance(node, ast.URLToken): new_url = self.url_rewriter( node.value, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] diff --git a/tests/rewriting/test_css_rewriting.py b/tests/rewriting/test_css_rewriting.py index 3d7e094..96399c8 100644 --- a/tests/rewriting/test_css_rewriting.py +++ b/tests/rewriting/test_css_rewriting.py @@ -216,3 +216,67 @@ def test_rewrite(): ).rewrite(content) == expected ) + + +@pytest.mark.parametrize( + "css", + [ + r'.test { content="\e123" }', + r'.test { content: "\e123" }', + r'.test::before { content: "\f101" }', + r'.test::before { content: "\e123\e124" }', + r'.test::before { content: "a\"b\\c" }', + r'.test { content: "\A " }', + r'.test { content: "\D " }', + r'.test { content: "\C " }', + r'.test { content: "\e123a" }', + r'.test { content: "a\\" }', + '"', + ], +) +def test_escaped_chars_are_kept(css: str): + rewriter = CssRewriter( + ArticleUrlRewriter(article_url=HttpUrl("http://kiwix.org/")), + base_href=None, + ) + assert rewriter.rewrite(css) == css + + +@pytest.mark.parametrize( + "css, expected", + [ + (r'.test { content: "\e123 a" }', r'.test { content: "\e123 a" }'), + (r'.test { content: "\e123 x" }', r'.test { content: "\e123x" }'), + (r'.test { content: "\e123 x" }', r'.test { content: "\e123 x" }'), + ], +) +def test_escaped_chars_separator(css: str, expected: str): + rewriter = CssRewriter( + ArticleUrlRewriter(article_url=HttpUrl("http://kiwix.org/")), + base_href=None, + ) + assert rewriter.rewrite(css) == expected + + +def test_escaped_chars_and_url_rewriting(): + rewriter = CssRewriter( + ArticleUrlRewriter(article_url=HttpUrl("http://kiwix.org/")), + base_href=None, + ) + assert ( + rewriter.rewrite( + r'.a { content: "\e123"; background: url(http://exemple.com/img.png) }' + ) + == r'.a { content: "\e123"; background: url(../exemple.com/img.png) }' + ) + + +def test_unterminated_string_ending_in_escaped_quote(): + rewriter = CssRewriter( + ArticleUrlRewriter(article_url=HttpUrl("http://kiwix.org/")), + base_href=None, + ) + assert ( + rewriter.rewrite(r'.test { content: "unterminated\" }') + == r'.test { content: "unterminated\" }}' + )