Skip to content
Open
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
41 changes: 41 additions & 0 deletions src/zimscraperlib/rewriting/css.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"""

import re
import string
import unicodedata
from collections.abc import Iterable, Sequence
from functools import partial
from typing import Any
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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]
Expand Down
64 changes: 64 additions & 0 deletions tests/rewriting/test_css_rewriting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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\" }}'
)