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 ]'
@cache
def get_phrasing_tags():
33@cache
34def get_phrasing_tags():
35    """
36    Get the list of phrasing tags from the HTML spec
37    """
38    result = []
39    for e in dir(el_list):
40        val = getattr(el_list, e)
41        if isinstance(val, type) and issubclass(val, BaseElement):
42            if val is BaseElement:
43                continue
44
45            categories = getattr(val, "categories")
46            tag = getattr(val, "tag")
47            for c in categories:
48                if "phrasing" in c:
49                    result.append(tag)
50    return result

Get the list of phrasing tags from the HTML spec

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 []
elements
tags
import_statement
custom_elements
def as_array(self):
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]"

Return the elements as an array

def is_preformatted(tag_name):
157def is_preformatted(tag_name):
158    return tag_name in {"pre", "textarea"}
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.