html_compose.translate_html
1import inspect 2import re 3from functools import cache 4from typing import Any 5 6from bs4 import BeautifulSoup, NavigableString, PageElement, Tag 7from bs4.element import Doctype 8 9from . import BaseElement, escape_text 10from . import elements as el_list 11from .custom_element import CustomElement 12from .util_funcs import safe_name 13 14SPEC_WS = r"[\t\n\r ]" 15 16 17def _neighbor_tags(node: NavigableString) -> tuple[Tag | None, Tag | None]: 18 """Return the closest Tag siblings to the left and right of a text node.""" 19 20 def _find_tag_sibling(node, attr) -> Tag | None: 21 sibling = getattr(node, attr) 22 while sibling and not isinstance(sibling, Tag): 23 sibling = getattr(sibling, attr) 24 return sibling if isinstance(sibling, Tag) else None 25 26 return ( 27 _find_tag_sibling(node, "previous_sibling"), 28 _find_tag_sibling(node, "next_sibling"), 29 ) 30 31 32@cache 33def get_phrasing_tags(): 34 """ 35 Get the list of phrasing tags from the HTML spec 36 """ 37 result = [] 38 for e in dir(el_list): 39 val = getattr(el_list, e) 40 if isinstance(val, type) and issubclass(val, BaseElement): 41 if val is BaseElement: 42 continue 43 44 categories = getattr(val, "categories") 45 tag = getattr(val, "tag") 46 for c in categories: 47 if "phrasing" in c: 48 result.append(tag) 49 return result 50 51 52def read_string( 53 input_str: NavigableString, 54 prev_tag: Tag | None, 55 next_tag: Tag | None, 56 phrasing_tags: list[str], 57) -> str | None: 58 """ 59 Helper to sort of 'auto-translate' HTML formatted strings into what 60 they would be viewed as in a browser, which can then be represented in 61 Python. 62 63 It collapses whitespace based on the context of the surrounding tags. 64 """ 65 text = str(input_str) 66 67 # Trim if the previous or next tag is not an inline (phrasing) tag 68 trim_left = False 69 if prev_tag: 70 trim_left = prev_tag.name not in phrasing_tags 71 else: 72 # No previous sibling, trim leading space 73 trim_left = True 74 75 trim_right = False 76 if next_tag: 77 trim_right = next_tag.name not in phrasing_tags 78 else: 79 # No next sibling, trim trailing space 80 trim_right = True 81 82 if trim_left: 83 text = text.lstrip() 84 if trim_right: 85 text = text.rstrip() 86 87 # Collapse multiple whitespace characters into a single space 88 result = re.sub(f"{SPEC_WS}+", " ", text) 89 90 if not result: 91 return None 92 93 # If the original string was just whitespace and it got completely removed, 94 # but it was between two inline tags, we should preserve a single space. 95 if not result and input_str.strip() == "": 96 if ( 97 prev_tag 98 and next_tag 99 and prev_tag.name in phrasing_tags 100 and next_tag.name in phrasing_tags 101 ): 102 return repr(" ") 103 104 if escape_text(result) != result: 105 # If the text (e.g., '&', '<'), would be escaped, 106 # To preserve the exact parsed string, we must wrap it in `unsafe_text`. 107 return f"unsafe_text({repr(result)})" 108 109 return repr(result) 110 111 112# HTML spec doesn't say this casually, but these are preformatted. 113WHITESPACE_PRE = ["pre", "textarea", "listing", "xmp"] 114 115 116def read_pre_string(input_str: NavigableString) -> str | None: 117 """ 118 pre elements do the same as above, but remove the first newline 119 """ 120 result = re.sub("^\n", "", input_str) 121 if not result: 122 return None 123 124 if escape_text(result) != result: 125 # If the text (e.g., '&', '<'), would be escaped, 126 # To preserve the exact parsed string, we must wrap it in `unsafe_text`. 127 return f"unsafe_text({repr(result)})" 128 return repr(result) 129 130 131class TranslateResult: 132 """ 133 Class to hold the result of the translation 134 """ 135 136 def __init__( 137 self, 138 elements: list[str], 139 tags: dict[str, Any], 140 import_statement: str = "", 141 custom_elements: list[str] | None = None, 142 ): 143 self.elements = elements 144 self.tags = tags 145 self.import_statement = import_statement 146 self.custom_elements = custom_elements or [] 147 148 def as_array(self): 149 """ 150 Return the elements as an array 151 """ 152 sep = ",\n" 153 return f"[\n{sep.join(self.elements)}\n]" 154 155 156def is_preformatted(tag_name): 157 return tag_name in {"pre", "textarea"} 158 159 160def translate( 161 html: str, import_module: str | None = None, constructor: bool = False 162) -> TranslateResult: 163 """ 164 Translate HTML string into Python code representing a similar HTML structure 165 166 We try to strip aesthetic line breaks from original HTML in this process. 167 """ 168 soup = BeautifulSoup(html, features="html.parser") 169 170 tags: dict[str, Any] = {} 171 prefix = "" 172 if import_module is not None: 173 prefix = import_module + ("." if import_module else "") 174 175 custom_elements = set() 176 177 phrasing_tags = get_phrasing_tags() 178 179 import_unsafe_text = False 180 181 def process_element(element: PageElement) -> str | None: 182 if isinstance(element, Doctype): 183 dt: Doctype = element 184 tags["doctype"] = None 185 return f"doctype({repr(dt)})" 186 elif isinstance(element, NavigableString): 187 prev_tag, next_tag = _neighbor_tags(element) 188 return read_string(element, prev_tag, next_tag, phrasing_tags) 189 190 assert isinstance(element, Tag) 191 safe_tag_name = safe_name(element.name) 192 if safe_tag_name not in tags: 193 try: 194 tags[safe_tag_name] = getattr(el_list, safe_tag_name) 195 except AttributeError: 196 # This is a custom element, let's add it to the list 197 tags["CustomElement"] = None 198 tags[safe_tag_name] = CustomElement.create(safe_tag_name) 199 custom_elements.add(safe_tag_name) 200 is_custom = safe_tag_name in custom_elements 201 tag_cls = tags[safe_tag_name] 202 203 if is_custom: 204 # Custom elements aren't imported 205 result = [f"{safe_tag_name}"] 206 else: 207 result = [f"{prefix}{safe_tag_name}"] 208 209 if element.attrs: 210 param_attrs = {} 211 dict_attrs = {} 212 tag_keys = inspect.signature(tag_cls.__init__).parameters.keys() 213 214 for key, value in element.attrs.items(): 215 # value bs4 gives us is sometimes 216 # like (key='rel', value=['preconnect']) 217 # If the attribute value is a list of one item, unwrap it 218 if isinstance(value, list) and len(value) == 1: 219 value = value[0] 220 221 if key in ("attrs", "self", "children"): 222 # These are params of the constructor but the HTML given 223 # clashes with them 224 dict_attrs[key] = value 225 continue 226 227 safe_attr_name = safe_name(key) 228 229 if safe_attr_name in tag_keys: 230 param_attrs[safe_attr_name] = value 231 else: 232 # This is an unknown attribute, 233 # let's include it as a dictionary key/value 234 dict_attrs[key] = value 235 236 # Build element constructor call 237 result.append("(") 238 239 # Dict attributes first positionally 240 if dict_attrs: 241 result.append(repr(dict_attrs)) 242 243 # Matching keyword args 244 if param_attrs: 245 if dict_attrs: 246 result.append(", ") 247 params = [] 248 for key, value in param_attrs.items(): 249 params.append(f"{key}={repr(value)}") 250 result.append(", ".join(params)) 251 252 result.append(")") 253 else: 254 # If no attributes, we can skip the constructor call unless 255 if constructor: 256 result.append("()") 257 258 children: list[str] = [] 259 child_nodes = list(element.children) 260 for i, child in enumerate(child_nodes): 261 if element.name in WHITESPACE_PRE and isinstance( 262 child, NavigableString 263 ): 264 processed = read_pre_string(child) 265 if processed: 266 children.append(processed) 267 continue 268 269 if isinstance(child, NavigableString): 270 prev_tag, next_tag = _neighbor_tags(child) 271 processed = read_string( 272 child, prev_tag, next_tag, phrasing_tags 273 ) 274 if processed: 275 children.append(processed) 276 elif isinstance(child, Tag): 277 processed = process_element(child) 278 if processed: 279 children.append(processed) 280 for text_element in children: 281 if text_element.startswith("unsafe_text("): 282 nonlocal import_unsafe_text 283 import_unsafe_text = True 284 if children: 285 result.append("[") 286 result.append(", ".join(children)) 287 result.append("]") 288 return "".join(result) 289 290 elements = [process_element(child) for child in soup.children] 291 import_statement = "" 292 if not import_module: 293 keys = [key for key in tags.keys() if key not in custom_elements] 294 if len(keys) > 3: 295 # Add parens 296 import_statement = f"from html_compose import ({', '.join(keys)})" 297 else: 298 import_statement = f"from html_compose import {', '.join(keys)}" 299 300 if import_unsafe_text: 301 import_statement += ", unsafe_text" 302 else: 303 if import_module == "html_compose": 304 import_statement = "import html_compose" 305 else: 306 import_statement = f"import html_compose as {import_module}" 307 308 if import_unsafe_text: 309 import_statement += "\nfrom html_compose import unsafe_text" 310 311 custom_el_stmts = [ 312 f'{e} = {prefix}CustomElement.create("{e}")' for e in custom_elements 313 ] 314 315 return TranslateResult( 316 [e for e in elements if e], tags, import_statement, custom_el_stmts 317 )
SPEC_WS =
'[\\t\\n\\r ]'
def
read_string( input_str: bs4.element.NavigableString, prev_tag: bs4.element.Tag | None, next_tag: bs4.element.Tag | None, phrasing_tags: list[str]) -> str | None:
53def read_string( 54 input_str: NavigableString, 55 prev_tag: Tag | None, 56 next_tag: Tag | None, 57 phrasing_tags: list[str], 58) -> str | None: 59 """ 60 Helper to sort of 'auto-translate' HTML formatted strings into what 61 they would be viewed as in a browser, which can then be represented in 62 Python. 63 64 It collapses whitespace based on the context of the surrounding tags. 65 """ 66 text = str(input_str) 67 68 # Trim if the previous or next tag is not an inline (phrasing) tag 69 trim_left = False 70 if prev_tag: 71 trim_left = prev_tag.name not in phrasing_tags 72 else: 73 # No previous sibling, trim leading space 74 trim_left = True 75 76 trim_right = False 77 if next_tag: 78 trim_right = next_tag.name not in phrasing_tags 79 else: 80 # No next sibling, trim trailing space 81 trim_right = True 82 83 if trim_left: 84 text = text.lstrip() 85 if trim_right: 86 text = text.rstrip() 87 88 # Collapse multiple whitespace characters into a single space 89 result = re.sub(f"{SPEC_WS}+", " ", text) 90 91 if not result: 92 return None 93 94 # If the original string was just whitespace and it got completely removed, 95 # but it was between two inline tags, we should preserve a single space. 96 if not result and input_str.strip() == "": 97 if ( 98 prev_tag 99 and next_tag 100 and prev_tag.name in phrasing_tags 101 and next_tag.name in phrasing_tags 102 ): 103 return repr(" ") 104 105 if escape_text(result) != result: 106 # If the text (e.g., '&', '<'), would be escaped, 107 # To preserve the exact parsed string, we must wrap it in `unsafe_text`. 108 return f"unsafe_text({repr(result)})" 109 110 return repr(result)
Helper to sort of 'auto-translate' HTML formatted strings into what they would be viewed as in a browser, which can then be represented in Python.
It collapses whitespace based on the context of the surrounding tags.
WHITESPACE_PRE =
['pre', 'textarea', 'listing', 'xmp']
def
read_pre_string(input_str: bs4.element.NavigableString) -> str | None:
117def read_pre_string(input_str: NavigableString) -> str | None: 118 """ 119 pre elements do the same as above, but remove the first newline 120 """ 121 result = re.sub("^\n", "", input_str) 122 if not result: 123 return None 124 125 if escape_text(result) != result: 126 # If the text (e.g., '&', '<'), would be escaped, 127 # To preserve the exact parsed string, we must wrap it in `unsafe_text`. 128 return f"unsafe_text({repr(result)})" 129 return repr(result)
pre elements do the same as above, but remove the first newline
class
TranslateResult:
132class TranslateResult: 133 """ 134 Class to hold the result of the translation 135 """ 136 137 def __init__( 138 self, 139 elements: list[str], 140 tags: dict[str, Any], 141 import_statement: str = "", 142 custom_elements: list[str] | None = None, 143 ): 144 self.elements = elements 145 self.tags = tags 146 self.import_statement = import_statement 147 self.custom_elements = custom_elements or [] 148 149 def as_array(self): 150 """ 151 Return the elements as an array 152 """ 153 sep = ",\n" 154 return f"[\n{sep.join(self.elements)}\n]"
Class to hold the result of the translation
TranslateResult( elements: list[str], tags: dict[str, typing.Any], import_statement: str = '', custom_elements: list[str] | None = None)
137 def __init__( 138 self, 139 elements: list[str], 140 tags: dict[str, Any], 141 import_statement: str = "", 142 custom_elements: list[str] | None = None, 143 ): 144 self.elements = elements 145 self.tags = tags 146 self.import_statement = import_statement 147 self.custom_elements = custom_elements or []
def
is_preformatted(tag_name):
def
translate( html: str, import_module: str | None = None, constructor: bool = False) -> TranslateResult:
161def translate( 162 html: str, import_module: str | None = None, constructor: bool = False 163) -> TranslateResult: 164 """ 165 Translate HTML string into Python code representing a similar HTML structure 166 167 We try to strip aesthetic line breaks from original HTML in this process. 168 """ 169 soup = BeautifulSoup(html, features="html.parser") 170 171 tags: dict[str, Any] = {} 172 prefix = "" 173 if import_module is not None: 174 prefix = import_module + ("." if import_module else "") 175 176 custom_elements = set() 177 178 phrasing_tags = get_phrasing_tags() 179 180 import_unsafe_text = False 181 182 def process_element(element: PageElement) -> str | None: 183 if isinstance(element, Doctype): 184 dt: Doctype = element 185 tags["doctype"] = None 186 return f"doctype({repr(dt)})" 187 elif isinstance(element, NavigableString): 188 prev_tag, next_tag = _neighbor_tags(element) 189 return read_string(element, prev_tag, next_tag, phrasing_tags) 190 191 assert isinstance(element, Tag) 192 safe_tag_name = safe_name(element.name) 193 if safe_tag_name not in tags: 194 try: 195 tags[safe_tag_name] = getattr(el_list, safe_tag_name) 196 except AttributeError: 197 # This is a custom element, let's add it to the list 198 tags["CustomElement"] = None 199 tags[safe_tag_name] = CustomElement.create(safe_tag_name) 200 custom_elements.add(safe_tag_name) 201 is_custom = safe_tag_name in custom_elements 202 tag_cls = tags[safe_tag_name] 203 204 if is_custom: 205 # Custom elements aren't imported 206 result = [f"{safe_tag_name}"] 207 else: 208 result = [f"{prefix}{safe_tag_name}"] 209 210 if element.attrs: 211 param_attrs = {} 212 dict_attrs = {} 213 tag_keys = inspect.signature(tag_cls.__init__).parameters.keys() 214 215 for key, value in element.attrs.items(): 216 # value bs4 gives us is sometimes 217 # like (key='rel', value=['preconnect']) 218 # If the attribute value is a list of one item, unwrap it 219 if isinstance(value, list) and len(value) == 1: 220 value = value[0] 221 222 if key in ("attrs", "self", "children"): 223 # These are params of the constructor but the HTML given 224 # clashes with them 225 dict_attrs[key] = value 226 continue 227 228 safe_attr_name = safe_name(key) 229 230 if safe_attr_name in tag_keys: 231 param_attrs[safe_attr_name] = value 232 else: 233 # This is an unknown attribute, 234 # let's include it as a dictionary key/value 235 dict_attrs[key] = value 236 237 # Build element constructor call 238 result.append("(") 239 240 # Dict attributes first positionally 241 if dict_attrs: 242 result.append(repr(dict_attrs)) 243 244 # Matching keyword args 245 if param_attrs: 246 if dict_attrs: 247 result.append(", ") 248 params = [] 249 for key, value in param_attrs.items(): 250 params.append(f"{key}={repr(value)}") 251 result.append(", ".join(params)) 252 253 result.append(")") 254 else: 255 # If no attributes, we can skip the constructor call unless 256 if constructor: 257 result.append("()") 258 259 children: list[str] = [] 260 child_nodes = list(element.children) 261 for i, child in enumerate(child_nodes): 262 if element.name in WHITESPACE_PRE and isinstance( 263 child, NavigableString 264 ): 265 processed = read_pre_string(child) 266 if processed: 267 children.append(processed) 268 continue 269 270 if isinstance(child, NavigableString): 271 prev_tag, next_tag = _neighbor_tags(child) 272 processed = read_string( 273 child, prev_tag, next_tag, phrasing_tags 274 ) 275 if processed: 276 children.append(processed) 277 elif isinstance(child, Tag): 278 processed = process_element(child) 279 if processed: 280 children.append(processed) 281 for text_element in children: 282 if text_element.startswith("unsafe_text("): 283 nonlocal import_unsafe_text 284 import_unsafe_text = True 285 if children: 286 result.append("[") 287 result.append(", ".join(children)) 288 result.append("]") 289 return "".join(result) 290 291 elements = [process_element(child) for child in soup.children] 292 import_statement = "" 293 if not import_module: 294 keys = [key for key in tags.keys() if key not in custom_elements] 295 if len(keys) > 3: 296 # Add parens 297 import_statement = f"from html_compose import ({', '.join(keys)})" 298 else: 299 import_statement = f"from html_compose import {', '.join(keys)}" 300 301 if import_unsafe_text: 302 import_statement += ", unsafe_text" 303 else: 304 if import_module == "html_compose": 305 import_statement = "import html_compose" 306 else: 307 import_statement = f"import html_compose as {import_module}" 308 309 if import_unsafe_text: 310 import_statement += "\nfrom html_compose import unsafe_text" 311 312 custom_el_stmts = [ 313 f'{e} = {prefix}CustomElement.create("{e}")' for e in custom_elements 314 ] 315 316 return TranslateResult( 317 [e for e in elements if e], tags, import_statement, custom_el_stmts 318 )
Translate HTML string into Python code representing a similar HTML structure
We try to strip aesthetic line breaks from original HTML in this process.