html.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. from __future__ import annotations
  2. import xml.dom.minidom as minidom
  3. from string import Formatter
  4. from typing import Any
  5. from .base import FormattedText, StyleAndTextTuples
  6. __all__ = ["HTML"]
  7. class HTML:
  8. """
  9. HTML formatted text.
  10. Take something HTML-like, for use as a formatted string.
  11. ::
  12. # Turn something into red.
  13. HTML('<style fg="ansired" bg="#00ff44">...</style>')
  14. # Italic, bold, underline and strike.
  15. HTML('<i>...</i>')
  16. HTML('<b>...</b>')
  17. HTML('<u>...</u>')
  18. HTML('<s>...</s>')
  19. All HTML elements become available as a "class" in the style sheet.
  20. E.g. ``<username>...</username>`` can be styled, by setting a style for
  21. ``username``.
  22. """
  23. def __init__(self, value: str) -> None:
  24. self.value = value
  25. document = minidom.parseString(f"<html-root>{value}</html-root>")
  26. result: StyleAndTextTuples = []
  27. name_stack: list[str] = []
  28. fg_stack: list[str] = []
  29. bg_stack: list[str] = []
  30. def get_current_style() -> str:
  31. "Build style string for current node."
  32. parts = []
  33. if name_stack:
  34. parts.append("class:" + ",".join(name_stack))
  35. if fg_stack:
  36. parts.append("fg:" + fg_stack[-1])
  37. if bg_stack:
  38. parts.append("bg:" + bg_stack[-1])
  39. return " ".join(parts)
  40. def process_node(node: Any) -> None:
  41. "Process node recursively."
  42. for child in node.childNodes:
  43. if child.nodeType == child.TEXT_NODE:
  44. result.append((get_current_style(), child.data))
  45. else:
  46. add_to_name_stack = child.nodeName not in (
  47. "#document",
  48. "html-root",
  49. "style",
  50. )
  51. fg = bg = ""
  52. for k, v in child.attributes.items():
  53. if k == "fg":
  54. fg = v
  55. if k == "bg":
  56. bg = v
  57. if k == "color":
  58. fg = v # Alias for 'fg'.
  59. # Check for spaces in attributes. This would result in
  60. # invalid style strings otherwise.
  61. if " " in fg:
  62. raise ValueError('"fg" attribute contains a space.')
  63. if " " in bg:
  64. raise ValueError('"bg" attribute contains a space.')
  65. if add_to_name_stack:
  66. name_stack.append(child.nodeName)
  67. if fg:
  68. fg_stack.append(fg)
  69. if bg:
  70. bg_stack.append(bg)
  71. process_node(child)
  72. if add_to_name_stack:
  73. name_stack.pop()
  74. if fg:
  75. fg_stack.pop()
  76. if bg:
  77. bg_stack.pop()
  78. process_node(document)
  79. self.formatted_text = FormattedText(result)
  80. def __repr__(self) -> str:
  81. return f"HTML({self.value!r})"
  82. def __pt_formatted_text__(self) -> StyleAndTextTuples:
  83. return self.formatted_text
  84. def format(self, *args: object, **kwargs: object) -> HTML:
  85. """
  86. Like `str.format`, but make sure that the arguments are properly
  87. escaped.
  88. """
  89. return HTML(FORMATTER.vformat(self.value, args, kwargs))
  90. def __mod__(self, value: object) -> HTML:
  91. """
  92. HTML('<b>%s</b>') % value
  93. """
  94. if not isinstance(value, tuple):
  95. value = (value,)
  96. value = tuple(html_escape(i) for i in value)
  97. return HTML(self.value % value)
  98. class HTMLFormatter(Formatter):
  99. def format_field(self, value: object, format_spec: str) -> str:
  100. return html_escape(format(value, format_spec))
  101. def html_escape(text: object) -> str:
  102. # The string interpolation functions also take integers and other types.
  103. # Convert to string first.
  104. if not isinstance(text, str):
  105. text = f"{text}"
  106. return (
  107. text.replace("&", "&amp;")
  108. .replace("<", "&lt;")
  109. .replace(">", "&gt;")
  110. .replace('"', "&quot;")
  111. )
  112. FORMATTER = HTMLFormatter()