Coverage for utilities/html.py: 24%

38 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import re 

2 

3import nh3 

4from django.utils.html import escape 

5 

6from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS, IMAGE_URL_SCHEMES 

7 

8__all__ = ( 

9 'clean_html', 

10 'foreground_color', 

11 'highlight', 

12) 

13 

14SCHEME_RE = re.compile(r'^([a-zA-Z][a-zA-Z0-9+.-]*):') 

15 

16# Per the URL spec, browsers ignore leading/trailing C0 control characters & space, and strip any tab or 

17# newline characters appearing within a URL. We must normalize accordingly before checking the scheme. 

18URL_STRIP_CHARS = ''.join(chr(c) for c in range(0x21)) 

19URL_REMOVE_CHARS = str.maketrans('', '', '\t\r\n') 

20 

21 

22def _attribute_filter(tag, attr, value): 

23 """Returns str to keep/modify attribute, None to remove it.""" 

24 if tag == 'img' and attr == 'src': 

25 match = SCHEME_RE.match(value.strip(URL_STRIP_CHARS).translate(URL_REMOVE_CHARS)) 

26 if match and match.group(1).lower() not in IMAGE_URL_SCHEMES: 

27 return None 

28 return value 

29 

30 

31def clean_html(html, schemes): 

32 """ 

33 Sanitizes HTML based on a whitelist of allowed tags and attributes. 

34 Also takes a list of allowed URI schemes. 

35 """ 

36 url_schemes = set(schemes) 

37 attribute_filter = None if url_schemes <= IMAGE_URL_SCHEMES else _attribute_filter 

38 return nh3.clean( 

39 html, 

40 tags=HTML_ALLOWED_TAGS, 

41 attributes=HTML_ALLOWED_ATTRIBUTES, 

42 url_schemes=url_schemes, 

43 attribute_filter=attribute_filter, 

44 ) 

45 

46 

47def foreground_color(bg_color, dark='000000', light='ffffff'): 

48 """ 

49 Return the ideal foreground color (dark or light) for a given background color in hexadecimal RGB format. 

50 

51 :param dark: RBG color code for dark text 

52 :param light: RBG color code for light text 

53 """ 

54 THRESHOLD = 150 

55 bg_color = bg_color.strip('#') 

56 r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)] 

57 if r * 0.299 + g * 0.587 + b * 0.114 > THRESHOLD: 

58 return dark 

59 return light 

60 

61 

62def highlight(value, highlight, trim_pre=None, trim_post=None, trim_placeholder='...'): 

63 """ 

64 Highlight a string within a string and optionally trim the pre/post portions of the original string. 

65 

66 Args: 

67 value: The body of text being searched against 

68 highlight: The string of compiled regex pattern to highlight in `value` 

69 trim_pre: Maximum length of pre-highlight text to include 

70 trim_post: Maximum length of post-highlight text to include 

71 trim_placeholder: String value to swap in for trimmed pre/post text 

72 """ 

73 # Split value on highlight string 

74 try: 

75 if type(highlight) is re.Pattern: 

76 pre, match, post = highlight.split(value, maxsplit=1) 

77 else: 

78 highlight = re.escape(highlight) 

79 pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE) 

80 except ValueError: 

81 # Match not found 

82 return escape(value) 

83 

84 # Trim pre/post sections to length 

85 if trim_pre and len(pre) > trim_pre: 

86 pre = trim_placeholder + pre[-trim_pre:] 

87 if trim_post and len(post) > trim_post: 

88 post = post[:trim_post] + trim_placeholder 

89 

90 return f'{escape(pre)}<mark>{escape(match)}</mark>{escape(post)}'