Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
__title__ = "draftjs_exporter"
__version__ = "5.0.0"
__author__ = "Springload"
__license__ = "MIT"
__copyright__ = "Copyright 2016-present Springload"

View File

@@ -0,0 +1,56 @@
from typing import List
from draftjs_exporter.types import Block
class Command:
"""
A Command represents an operation that has to be executed
on a block for it to be converted into an arbitrary number
of HTML nodes.
"""
__slots__ = ("name", "index", "data")
def __init__(self, name: str, index: int, data: str = "") -> None:
self.name = name
self.index = index
self.data = data
def __str__(self) -> str:
return f"<Command {self.name} {self.index} {self.data}>"
def __repr__(self) -> str:
return str(self)
@staticmethod
def from_entity_ranges(block: Block) -> List["Command"]:
"""
Creates a list of commands from a blocks list of entity ranges.
Each range is converted to two commands: a start_* and a stop_*.
"""
commands: List["Command"] = []
for r in block["entityRanges"]:
# Entity key is an integer in entity ranges, while a string in the entity map.
data = str(r["key"])
start = r["offset"]
stop = start + r["length"]
commands.append(Command("start_entity", start, data))
commands.append(Command("stop_entity", stop, data))
return commands
@staticmethod
def from_style_ranges(block: Block) -> List["Command"]:
"""
Creates a list of commands from a blocks list of style ranges.
Each range is converted to two commands: a start_* and a stop_*.
"""
commands: List["Command"] = []
for r in block["inlineStyleRanges"]:
data = r["style"]
start = r["offset"]
stop = start + r["length"]
commands.append(Command("start_inline_style", start, data))
commands.append(Command("stop_inline_style", stop, data))
return commands

View File

@@ -0,0 +1,93 @@
import re
from operator import itemgetter
from typing import Any, Dict, Generator, List, Sequence, Tuple
from draftjs_exporter.dom import DOM
from draftjs_exporter.types import (
Block,
CompositeDecorators,
Decorator,
Element,
)
br = "\n"
br_strategy = re.compile(r"\n")
def get_decorations(
decorators: CompositeDecorators, text: str
) -> List[Tuple[int, int, Any, Decorator]]:
occupied: Dict[int, int] = {}
decorations = []
for decorator in decorators:
for match in decorator["strategy"].finditer(text):
begin, end = match.span()
if not any(occupied.get(i) for i in range(begin, end)):
for i in range(begin, end):
occupied[i] = 1
decorations.append((begin, end, match, decorator))
decorations.sort(key=itemgetter(0))
return decorations
def apply_decorators(
decorators: CompositeDecorators,
text: str,
block: Block,
blocks: Sequence[Block],
) -> Generator[str, None, None]:
decorations = get_decorations(decorators, text)
pointer = 0
for begin, end, match, decorator in decorations:
if pointer < begin:
yield text[pointer:begin]
yield DOM.create_element(
decorator["component"],
{"match": match, "block": block, "blocks": blocks},
match.group(0),
)
pointer = end
if pointer < len(text):
yield text[pointer:]
def render_decorators(
decorators: CompositeDecorators,
text: str,
block: Block,
blocks: Sequence[Block],
) -> Element:
decorated_children = list(apply_decorators(decorators, text, block, blocks))
if len(decorated_children) == 1:
decorated_node = decorated_children[0]
else:
decorated_node = DOM.create_element()
for decorated_child in decorated_children:
DOM.append_child(decorated_node, decorated_child)
return decorated_node
def should_render_decorators(
decorators: CompositeDecorators,
text: str,
) -> bool:
nb_decorators = len(decorators)
if nb_decorators == 0:
return False
is_skippable_br = (
nb_decorators == 1
and decorators[0]["strategy"] == br_strategy
and not (br in text)
)
return not is_skippable_br

View File

@@ -0,0 +1,61 @@
# http://stackoverflow.com/a/22723724/1798491
class Enum:
__slots__ = "elements"
def __init__(self, *elements: str) -> None:
self.elements = tuple(elements)
def __getattr__(self, name: str) -> str:
if name not in self.elements:
raise AttributeError(f"'Enum' has no attribute '{name}'")
return name
# https://github.com/facebook/draft-js/blob/master/src/model/constants/DraftBlockType.js
class BLOCK_TYPES:
UNSTYLED = "unstyled"
HEADER_ONE = "header-one"
HEADER_TWO = "header-two"
HEADER_THREE = "header-three"
HEADER_FOUR = "header-four"
HEADER_FIVE = "header-five"
HEADER_SIX = "header-six"
UNORDERED_LIST_ITEM = "unordered-list-item"
ORDERED_LIST_ITEM = "ordered-list-item"
BLOCKQUOTE = "blockquote"
PRE = "pre"
CODE = "code-block"
ATOMIC = "atomic"
# Special type to configure handling of missing components.
FALLBACK = "fallback"
ENTITY_TYPES = Enum(
"LINK",
"DOCUMENT",
"IMAGE",
"EMBED",
"HORIZONTAL_RULE",
# Special type to configure handling of missing components.
"FALLBACK",
)
INLINE_STYLES = Enum(
"BOLD",
"CODE",
"ITALIC",
"UNDERLINE",
"STRIKETHROUGH",
"SUPERSCRIPT",
"SUBSCRIPT",
"MARK",
"QUOTATION",
"SMALL",
"SAMPLE",
"INSERT",
"DELETE",
"KEYBOARD",
# Special type to configure handling of missing components.
"FALLBACK",
)

View File

@@ -0,0 +1,56 @@
from draftjs_exporter.constants import BLOCK_TYPES, INLINE_STYLES
from draftjs_exporter.dom import DOM
from draftjs_exporter.types import Element, Props
def render_children(props: Props) -> Element:
"""
Renders the children of a component without any specific
markup for the component itself.
"""
return props["children"]
def code_block(props: Props) -> Element:
return DOM.create_element(
"pre", {}, DOM.create_element("code", {}, props["children"])
)
# Default block map to extend.
BLOCK_MAP = {
BLOCK_TYPES.UNSTYLED: "p",
BLOCK_TYPES.HEADER_ONE: "h1",
BLOCK_TYPES.HEADER_TWO: "h2",
BLOCK_TYPES.HEADER_THREE: "h3",
BLOCK_TYPES.HEADER_FOUR: "h4",
BLOCK_TYPES.HEADER_FIVE: "h5",
BLOCK_TYPES.HEADER_SIX: "h6",
BLOCK_TYPES.UNORDERED_LIST_ITEM: {"element": "li", "wrapper": "ul"},
BLOCK_TYPES.ORDERED_LIST_ITEM: {"element": "li", "wrapper": "ol"},
BLOCK_TYPES.BLOCKQUOTE: "blockquote",
BLOCK_TYPES.PRE: "pre",
BLOCK_TYPES.CODE: code_block,
BLOCK_TYPES.ATOMIC: render_children,
}
# Default style map to extend.
# Tags come from https://developer.mozilla.org/en-US/docs/Web/HTML/Element.
# and are loosely aligned with https://github.com/jpuri/draftjs-to-html.
# Only styles that map to HTML elements are allowed as defaults.
STYLE_MAP = {
INLINE_STYLES.BOLD: "strong",
INLINE_STYLES.CODE: "code",
INLINE_STYLES.ITALIC: "em",
INLINE_STYLES.UNDERLINE: "u",
INLINE_STYLES.STRIKETHROUGH: "s",
INLINE_STYLES.SUPERSCRIPT: "sup",
INLINE_STYLES.SUBSCRIPT: "sub",
INLINE_STYLES.MARK: "mark",
INLINE_STYLES.QUOTATION: "q",
INLINE_STYLES.SMALL: "small",
INLINE_STYLES.SAMPLE: "samp",
INLINE_STYLES.INSERT: "ins",
INLINE_STYLES.DELETE: "del",
INLINE_STYLES.KEYBOARD: "kbd",
}

View File

@@ -0,0 +1,130 @@
import re
from typing import Any, Optional
from draftjs_exporter.engines.base import DOMEngine
from draftjs_exporter.types import HTML, Element, Props, RenderableType
from draftjs_exporter.utils.module_loading import import_string
# https://gist.github.com/yahyaKacem/8170675
_first_cap_re = re.compile(r"(.)([A-Z][a-z]+)")
_all_cap_re = re.compile("([a-z0-9])([A-Z])")
class DOM:
"""
Component building API, abstracting the DOM implementation.
"""
HTML5LIB = "draftjs_exporter.engines.html5lib.DOM_HTML5LIB"
LXML = "draftjs_exporter.engines.lxml.DOM_LXML"
STRING = "draftjs_exporter.engines.string.DOMString"
STRING_COMPAT = "draftjs_exporter.engines.string_compat.DOMStringCompat"
dom: DOMEngine = None # type: ignore
@staticmethod
def camel_to_dash(camel_cased_str: str) -> str:
sub2 = _first_cap_re.sub(r"\1-\2", camel_cased_str)
dashed_case_str = _all_cap_re.sub(r"\1-\2", sub2).lower()
return dashed_case_str.replace("--", "-")
@classmethod
def use(cls, engine: str) -> None:
"""
Choose which DOM implementation to use.
"""
cls.dom = import_string(engine)
@classmethod
def create_element(
cls,
type_: RenderableType = None,
props: Optional[Props] = None,
*elt_children: Optional[Element],
) -> Element:
"""
Signature inspired by React.createElement.
createElement(
string/Component type,
[dict props],
[children ...]
)
https://facebook.github.io/react/docs/top-level-api.html#react.createelement
"""
# Create an empty document fragment.
if not type_:
return cls.dom.create_tag("fragment")
if props is None:
props = {}
# If the first element of children is a list, we use it as the list.
if len(elt_children) and isinstance(elt_children[0], (list, tuple)):
children = elt_children[0]
else:
children = elt_children
# The children prop is the first child if there is only one.
props["children"] = children[0] if len(children) == 1 else children
if callable(type_):
# Function component, via def or lambda.
elt = type_(props)
else:
# Raw tag, as a string.
attributes = {}
# Never render those attributes on a raw tag.
props.pop("children", None)
props.pop("block", None)
props.pop("blocks", None)
props.pop("entity", None)
props.pop("inline_style_range", None)
# Convert style object to style string, like the DOM would do.
if "style" in props and isinstance(props["style"], dict):
rules = [
f"{DOM.camel_to_dash(s)}: {v};"
for s, v in props["style"].items()
]
props["style"] = "".join(rules)
# Convert props to HTML attributes.
for key in props:
if props[key] is False:
props[key] = "false"
if props[key] is True:
props[key] = "true"
if props[key] is not None:
attributes[key] = str(props[key])
elt = cls.dom.create_tag(type_, attributes)
# Append the children inside the element.
for child in children:
if child not in (None, ""):
cls.append_child(elt, child)
# If elt is "empty", create a fragment anyway to add children.
if elt in (None, ""):
elt = cls.dom.create_tag("fragment")
return elt
@classmethod
def parse_html(cls, markup: HTML) -> Element:
return cls.dom.parse_html(markup)
@classmethod
def append_child(cls, elt: Element, child: Element) -> Any:
return cls.dom.append_child(elt, child)
@classmethod
def render(cls, elt: Element) -> HTML:
return cls.dom.render(elt)
@classmethod
def render_debug(cls, elt: Element) -> HTML:
return cls.dom.render_debug(elt)

View File

@@ -0,0 +1,50 @@
from typing import Any, Dict, Optional
from draftjs_exporter.types import HTML, Element, Tag
Attr = Dict[str, str]
class DOMEngine:
"""
Parent class of all DOM implementations.
"""
@staticmethod
def create_tag(type_: Tag, attr: Optional[Attr] = None) -> Any:
"""
Creates and returns a tree node of the given type and attributes.
"""
raise NotImplementedError
@staticmethod
def parse_html(markup: HTML) -> Element:
"""
Creates nodes based on the input html.
Note: this method is used in component implementations only, and
is not required for the exporter to operate.
"""
raise NotImplementedError
@staticmethod
def append_child(elt: Element, child: Element) -> Any:
"""
Appends the given child node in the children of elt.
"""
raise NotImplementedError
@staticmethod
def render(elt: Element) -> HTML:
"""
Renders a given element to HTML.
"""
raise NotImplementedError
@staticmethod
def render_debug(elt: Element) -> HTML:
"""
Renders a given element to HTML.
Note: this method is only used for draftjs_exporter's tests, and
is not required for the exporter to operate.
"""
raise NotImplementedError

View File

@@ -0,0 +1,45 @@
import re
from typing import Optional
from draftjs_exporter.engines.base import Attr, DOMEngine
from draftjs_exporter.types import HTML, Element, Tag
try:
from bs4 import BeautifulSoup
# Cache empty soup so we can create tags in isolation without the performance overhead.
soup = BeautifulSoup("", "html5lib")
except ImportError:
pass
RENDER_RE = re.compile(r"</?(fragment|body|html|head)>")
RENDER_DEBUG_RE = re.compile(r"</?(body|html|head)>")
class DOM_HTML5LIB(DOMEngine):
"""
html5lib implementation of the DOM API.
"""
@staticmethod
def create_tag(type_: Tag, attr: Optional[Attr] = None) -> Element:
if not attr:
attr = {}
return soup.new_tag(type_, **attr)
@staticmethod
def parse_html(markup: HTML) -> Element:
return BeautifulSoup(markup, "html5lib")
@staticmethod
def append_child(elt: Element, child: Element) -> None:
elt.append(child)
@staticmethod
def render(elt: Element) -> HTML:
return RENDER_RE.sub("", str(elt))
@staticmethod
def render_debug(elt: Element) -> HTML:
return RENDER_DEBUG_RE.sub("", str(elt))

View File

@@ -0,0 +1,54 @@
import re
from typing import Optional
from draftjs_exporter.engines.base import Attr, DOMEngine
from draftjs_exporter.types import HTML, Tag
try:
from lxml import etree, html
except ImportError:
pass
NSMAP = {"xlink": "http://www.w3.org/1999/xlink"}
RENDER_RE = re.compile(r"</?fragment>")
class DOM_LXML(DOMEngine):
"""
lxml implementation of the DOM API.
"""
@staticmethod
def create_tag(type_: Tag, attr: Optional[Attr] = None) -> etree.Element:
nsmap = None
if attr:
if "xlink:href" in attr:
attr[f"{{{NSMAP['xlink']}}}href"] = attr.pop("xlink:href")
nsmap = NSMAP
return etree.Element(type_, attrib=attr, nsmap=nsmap)
@staticmethod
def parse_html(markup: HTML) -> etree.Element:
return html.fromstring(markup)
@staticmethod
def append_child(elt: etree.Element, child: etree.Element) -> None:
if hasattr(child, "tag"):
elt.append(child)
else:
c = etree.Element("fragment")
c.text = child
elt.append(c)
@staticmethod
def render(elt: etree.Element) -> HTML:
return RENDER_RE.sub(
"", etree.tostring(elt, method="html", encoding="unicode")
)
@staticmethod
def render_debug(elt: etree.Element) -> HTML:
return etree.tostring(elt, method="html", encoding="unicode")

View File

@@ -0,0 +1,122 @@
from html import escape
from typing import List, Optional, Sequence, Union
from draftjs_exporter.engines.base import Attr, DOMEngine
from draftjs_exporter.types import HTML, Tag
# http://w3c.github.io/html/single-page.html#void-elements
# https://github.com/html5lib/html5lib-python/blob/0cae52b2073e3f2220db93a7650901f2200f2a13/html5lib/constants.py#L560
VOID_ELEMENTS = (
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
)
class Elt:
"""
A DOM element that the string engine manipulates.
This class doesn't do much, but the exporter relies on
comparing elements by reference so it's useful nonetheless.
"""
__slots__ = ("type", "attr", "children", "markup")
def __init__(self, type_: Tag, attr: Optional[Attr], markup: HTML = None):
self.type = type_
self.attr = attr
self.children: List["Elt"] = []
self.markup = markup
@staticmethod
def from_html(markup: HTML) -> "Elt":
return Elt("escaped_html", None, markup)
class DOMString(DOMEngine):
"""
String concatenation implementation of the DOM API.
"""
@staticmethod
def create_tag(type_: Tag, attr: Optional[Attr] = None) -> Elt:
return Elt(type_, attr)
@staticmethod
def parse_html(markup: HTML) -> Elt:
"""
Allows inserting arbitrary HTML into the exporter output.
Treats the HTML as if it had been escaped and was safe already.
"""
return Elt.from_html(markup)
@staticmethod
def append_child(elt: Elt, child: Elt) -> None:
# This check is necessary because the current wrapper_state implementation
# has an issue where it inserts elements multiple times.
# This must be skipped for text, which can be duplicated.
is_existing_ref = child in elt.children and isinstance(child, Elt)
if not is_existing_ref:
elt.children.append(child)
@staticmethod
def render_attrs(attr: Attr) -> str:
attrs = [f' {k}="{escape(v)}"' for k, v in attr.items()]
return "".join(attrs)
@staticmethod
def render_children(children: Sequence[Union[HTML, Elt]]) -> HTML:
return "".join(
[
DOMString.render(c)
if isinstance(c, Elt)
else escape(c, quote=False)
for c in children
]
)
@staticmethod
def render(elt: Elt) -> HTML:
type_ = elt.type
attr = DOMString.render_attrs(elt.attr) if elt.attr else ""
children = (
DOMString.render_children(elt.children) if elt.children else ""
)
if type_ == "fragment":
return children
if type_ in VOID_ELEMENTS:
return f"<{type_}{attr}/>"
if type_ == "escaped_html":
return elt.markup # type: ignore
return f"<{type_}{attr}>{children}</{type_}>"
@staticmethod
def render_debug(elt: Elt) -> HTML:
type_ = elt.type
attr = DOMString.render_attrs(elt.attr) if elt.attr else ""
children = (
DOMString.render_children(elt.children) if elt.children else ""
)
if type_ in VOID_ELEMENTS:
return f"<{type_}{attr}/>"
if type_ == "escaped_html":
return elt.markup # type: ignore
return f"<{type_}{attr}>{children}</{type_}>"

View File

@@ -0,0 +1,69 @@
from html import escape
from typing import Sequence, Union
from draftjs_exporter.engines.base import Attr
from draftjs_exporter.engines.string import DOMString, Elt, VOID_ELEMENTS
from draftjs_exporter.types import HTML
class DOMStringCompat(DOMString):
"""
The same as DOMString, but with as much backwards-compatibility as possible.
"""
@staticmethod
def render_attrs(attr: Attr) -> str:
attrs = [f' {k}="{escape(v)}"' for k, v in attr.items()]
# Compat: reverts "Remove HTML attributes alphabetical sorting of default string engine ([#129](https://github.com/springload/draftjs_exporter/pull/129))"
attrs.sort()
return "".join(attrs)
@staticmethod
def render_children(children: Sequence[Union[HTML, Elt]]) -> HTML:
return "".join(
[
DOMStringCompat.render(c) if isinstance(c, Elt)
# Compat: reverts "Disable single and double quotes escaping outside of attributes for string engine ([#129](https://github.com/springload/draftjs_exporter/pull/129))"
else escape(c, quote=True)
for c in children
]
)
@staticmethod
def render(elt: Elt) -> HTML:
type_ = elt.type
attr = DOMStringCompat.render_attrs(elt.attr) if elt.attr else ""
children = (
DOMStringCompat.render_children(elt.children)
if elt.children
else ""
)
if type_ == "fragment":
return children
if type_ in VOID_ELEMENTS:
return f"<{type_}{attr}/>"
if type_ == "escaped_html":
return elt.markup # type: ignore
return f"<{type_}{attr}>{children}</{type_}>"
@staticmethod
def render_debug(elt: Elt) -> HTML:
type_ = elt.type
attr = DOMStringCompat.render_attrs(elt.attr) if elt.attr else ""
children = (
DOMStringCompat.render_children(elt.children)
if elt.children
else ""
)
if type_ in VOID_ELEMENTS:
return f"<{type_}{attr}/>"
if type_ == "escaped_html":
return elt.markup # type: ignore
return f"<{type_}{attr}>{children}</{type_}>"

View File

@@ -0,0 +1,112 @@
from typing import List, Optional, Sequence
from draftjs_exporter.command import Command
from draftjs_exporter.constants import ENTITY_TYPES
from draftjs_exporter.dom import DOM
from draftjs_exporter.error import ExporterException
from draftjs_exporter.options import Options, OptionsMap
from draftjs_exporter.types import (
Block,
Element,
EntityDetails,
EntityKey,
EntityMap,
)
class EntityException(ExporterException):
pass
class EntityState:
__slots__ = (
"entity_options",
"entity_map",
"entity_stack",
"completed_entity",
"element_stack",
)
def __init__(
self, entity_options: OptionsMap, entity_map: EntityMap
) -> None:
self.entity_options = entity_options
self.entity_map = entity_map
self.entity_stack: List[EntityKey] = []
self.completed_entity: Optional[EntityKey] = None
self.element_stack: List[Element] = []
def apply(self, command: Command) -> None:
if command.name == "start_entity":
self.entity_stack.append(command.data)
elif command.name == "stop_entity":
expected_entity = self.entity_stack[-1]
if command.data != expected_entity:
raise EntityException(
f"Expected {expected_entity}, got {command.data}"
)
self.completed_entity = self.entity_stack.pop()
def has_entity(self) -> List[EntityKey]:
return self.entity_stack
def has_no_entity(self) -> bool:
return not self.entity_stack
def get_entity_details(self, entity_key: EntityKey) -> EntityDetails:
details = self.entity_map.get(entity_key)
if details is None:
raise EntityException(
f'Entity "{entity_key}" does not exist in the entityMap'
)
return details
def render_entities(
self, style_node: Element, block: Block, blocks: Sequence[Block]
) -> Element:
# We have a complete (start, stop) entity to render.
if self.completed_entity is not None:
entity_details = self.get_entity_details(self.completed_entity)
options = Options.get(
self.entity_options,
entity_details["type"],
ENTITY_TYPES.FALLBACK,
)
props = entity_details["data"].copy()
props["entity"] = {
"type": entity_details["type"],
"mutability": entity_details["mutability"]
if "mutability" in entity_details
else None,
"block": block,
"blocks": blocks,
"entity_range": {"key": self.completed_entity},
}
if len(self.element_stack) == 1:
children = self.element_stack[0]
else:
children = DOM.create_element()
for n in self.element_stack:
DOM.append_child(children, n)
self.completed_entity = None
self.element_stack = []
# Is there still another entity? (adjacent) if so add the current style_node for it.
if self.has_entity():
self.element_stack.append(style_node)
return DOM.create_element(options.element, props, children)
if self.has_entity():
self.element_stack.append(style_node)
return None
return style_node

View File

@@ -0,0 +1,6 @@
class ExporterException(Exception):
pass
class ConfigException(ExporterException):
pass

View File

@@ -0,0 +1,191 @@
from itertools import groupby
from operator import attrgetter
from typing import List, Optional, Tuple
from draftjs_exporter.command import Command
from draftjs_exporter.composite_decorators import (
render_decorators,
should_render_decorators,
)
from draftjs_exporter.defaults import BLOCK_MAP, STYLE_MAP
from draftjs_exporter.dom import DOM
from draftjs_exporter.entity_state import EntityState
from draftjs_exporter.options import Options
from draftjs_exporter.style_state import StyleState
from draftjs_exporter.types import (
Block,
Config,
ContentState,
Element,
EntityMap,
)
from draftjs_exporter.wrapper_state import WrapperState
class HTML:
"""
Entry point of the exporter. Combines entity, wrapper and style state
to generate the right HTML nodes.
"""
__slots__ = (
"composite_decorators",
"entity_options",
"block_options",
"style_options",
)
def __init__(self, config: Optional[Config] = None) -> None:
if config is None:
config = {}
self.composite_decorators = config.get("composite_decorators", [])
self.entity_options = Options.map_entities(
config.get("entity_decorators", {})
)
self.block_options = Options.map_blocks(
config.get("block_map", BLOCK_MAP)
)
self.style_options = Options.map_styles(
config.get("style_map", STYLE_MAP)
)
DOM.use(config.get("engine", DOM.STRING))
def render(self, content_state: Optional[ContentState] = None) -> str:
"""
Starts the export process on a given piece of content state.
"""
if content_state is None:
content_state = {}
blocks = content_state.get("blocks", [])
wrapper_state = WrapperState(self.block_options, blocks)
document = DOM.create_element()
entity_map = content_state.get("entityMap", {})
min_depth = 0
for block in blocks:
# Assume a depth of 0 if it's not specified, like Draft.js would.
depth = block["depth"] if "depth" in block else 0
elt = self.render_block(block, entity_map, wrapper_state)
if depth > min_depth:
min_depth = depth
# At level 0, append the element to the document.
if depth == 0:
DOM.append_child(document, elt)
# If there is no block at depth 0, we need to add the wrapper that contains the whole tree to the document.
if min_depth > 0 and wrapper_state.stack.length() != 0:
DOM.append_child(document, wrapper_state.stack.tail().elt)
return DOM.render(document)
def render_block(
self, block: Block, entity_map: EntityMap, wrapper_state: WrapperState
) -> Element:
has_styles = "inlineStyleRanges" in block and block["inlineStyleRanges"]
has_entities = "entityRanges" in block and block["entityRanges"]
has_decorators = should_render_decorators(
self.composite_decorators, block["text"]
)
if has_styles or has_entities:
content = DOM.create_element()
entity_state = EntityState(self.entity_options, entity_map)
style_state = StyleState(self.style_options) if has_styles else None
for (text, commands) in self.build_command_groups(block):
for command in commands:
entity_state.apply(command)
if style_state:
style_state.apply(command)
# Decorators are not rendered inside entities.
if has_decorators and entity_state.has_no_entity():
decorated_node = render_decorators(
self.composite_decorators,
text,
block,
wrapper_state.blocks,
)
else:
decorated_node = text
if style_state:
styled_node = style_state.render_styles(
decorated_node, block, wrapper_state.blocks
)
else:
styled_node = decorated_node
entity_node = entity_state.render_entities(
styled_node, block, wrapper_state.blocks
)
if entity_node is not None:
DOM.append_child(content, entity_node)
# Check whether there actually are two different nodes, confirming we are not inserting an upcoming entity.
if (
styled_node != entity_node
and entity_state.has_no_entity()
):
DOM.append_child(content, styled_node)
# Fast track for blocks which do not contain styles nor entities, which is very common.
elif has_decorators:
content = render_decorators(
self.composite_decorators,
block["text"],
block,
wrapper_state.blocks,
)
else:
content = block["text"]
return wrapper_state.element_for(block, content)
def build_command_groups(
self, block: Block
) -> List[Tuple[str, List[Command]]]:
"""
Creates block modification commands, grouped by start index,
with the text to apply them on.
"""
text = block["text"]
commands = self.build_commands(block)
grouped = groupby(commands, attrgetter("index"))
listed = list(groupby(commands, attrgetter("index")))
sliced = []
i = 0
for start_index, comms in grouped:
if i < len(listed) - 1:
stop_index = listed[i + 1][0]
sliced.append((text[start_index:stop_index], list(comms)))
else:
sliced.append(("", list(comms)))
i += 1
return sliced
def build_commands(self, block: Block) -> List[Command]:
"""
Build all of the manipulation commands for a given block.
- One pair to set the text.
- Multiple pairs for styles.
- Multiple pairs for entities.
"""
style_commands = Command.from_style_ranges(block)
entity_commands = Command.from_entity_ranges(block)
styles_and_entities = style_commands + entity_commands
styles_and_entities.sort(key=attrgetter("index"))
return (
[Command("start_text", 0)]
+ styles_and_entities
+ [Command("stop_text", len(block["text"]))]
)

View File

@@ -0,0 +1,105 @@
from typing import Any, Dict, Optional
from draftjs_exporter.constants import BLOCK_TYPES, ENTITY_TYPES, INLINE_STYLES
from draftjs_exporter.error import ConfigException
from draftjs_exporter.types import ConfigMap, Props, RenderableType
# Internal equivalent of a ConfigMap.
OptionsMap = Dict[str, "Options"]
class Options:
"""
Facilitates querying configuration from a config map.
"""
__slots__ = ("type", "element", "props", "wrapper", "wrapper_props")
def __init__(
self,
type_: str,
element: RenderableType,
props: Optional[Props] = None,
wrapper: RenderableType = None,
wrapper_props: Optional[Props] = None,
) -> None:
self.type = type_
self.element = element
self.props = props if props else {}
self.wrapper = wrapper
self.wrapper_props = wrapper_props
def __str__(self) -> str:
return f"<Options {self.type} {self.element} {self.props} {self.wrapper} {self.wrapper_props}>"
def __repr__(self) -> str:
return str(self)
def __eq__(self, other: Any) -> bool:
"""
Equality used in test code only, not to be relied on for the exporter.
"""
return str(self) == str(other)
def __ne__(self, other: Any) -> bool:
return not self == other
def __hash__(self) -> int:
return hash(str(self))
@staticmethod
def create(kind_map: ConfigMap, type_: str, fallback_key: str) -> "Options":
"""
Create an Options object from any mapping.
"""
if type_ not in kind_map:
if fallback_key not in kind_map:
raise ConfigException(
f'"{type_}" is not in the config and has no fallback'
)
config = kind_map[fallback_key]
else:
config = kind_map[type_]
if isinstance(config, dict):
if "element" not in config:
raise ConfigException(f'"{type_}" does not define an element')
opts = Options(type_, **config)
else:
opts = Options(type_, config)
return opts
@staticmethod
def map(kind_map: ConfigMap, fallback_key: str) -> OptionsMap:
options = {}
for type_ in kind_map:
options[type_] = Options.create(kind_map, type_, fallback_key)
return options
@staticmethod
def map_blocks(block_map: ConfigMap) -> OptionsMap:
return Options.map(block_map, BLOCK_TYPES.FALLBACK)
@staticmethod
def map_styles(style_map: ConfigMap) -> OptionsMap:
return Options.map(style_map, INLINE_STYLES.FALLBACK)
@staticmethod
def map_entities(entity_map: ConfigMap) -> OptionsMap:
return Options.map(entity_map, ENTITY_TYPES.FALLBACK)
@staticmethod
def get(options: OptionsMap, type_: str, fallback_key: str) -> "Options":
try:
return options[type_]
except KeyError:
try:
return options[fallback_key]
except KeyError:
raise ConfigException(
f'"{type_}" is not in the config and has no fallback'
)

View File

@@ -0,0 +1,51 @@
from typing import List, Sequence
from draftjs_exporter.command import Command
from draftjs_exporter.constants import INLINE_STYLES
from draftjs_exporter.dom import DOM
from draftjs_exporter.options import Options, OptionsMap
from draftjs_exporter.types import Block, Element
class StyleState:
"""
Handles the creation of inline styles on elements.
Receives inline_style commands, and generates the element's `style`
attribute from those.
"""
__slots__ = ("styles", "style_options")
def __init__(self, style_options: OptionsMap) -> None:
self.styles: List[str] = []
self.style_options = style_options
def apply(self, command: Command) -> None:
if command.name == "start_inline_style":
self.styles.append(command.data)
elif command.name == "stop_inline_style":
self.styles.remove(command.data)
def is_empty(self) -> bool:
return not self.styles
def render_styles(
self, decorated_node: Element, block: Block, blocks: Sequence[Block]
) -> Element:
node = decorated_node
if not self.is_empty():
# This will mutate self.styles, but its going to be reset after rendering anyway.
self.styles.sort(reverse=True)
# Nest the tags.
for style in self.styles:
options = Options.get(
self.style_options, style, INLINE_STYLES.FALLBACK
)
props = dict(options.props)
props["block"] = block
props["blocks"] = blocks
props["inline_style_range"] = {"style": style}
node = DOM.create_element(options.element, props, node)
return node

View File

@@ -0,0 +1,35 @@
from typing import Any, Callable, Dict, List, Mapping, Union
# Element represents an instance of a RenderableType. Its engine-specific so very hard to type.
Element = Any
# Props are always a dictionary with string keys and arbitrary values.
# TODO Other types than string for keys.
Props = Dict[str, Any]
# A DOM tag name.
Tag = str
# A component function, taking props as a parameter and returning an Element by calling DOM.create_element.
Component = Callable[[Props], Element]
# What can be rendered: None, DOM tag name, Component.
RenderableType = Union[None, Tag, Component]
# The output of the exporter.
HTML = str
# The whole config object.
Config = Dict[str, Any]
# block_map, style_map, entity_decorators.
# The dict could be limited to a fixed set of keys, but this would require TypedDict.
ConfigMap = Mapping[str, Union[Dict[str, Any], RenderableType]]
# composite_decorators.
Decorator = Dict[str, Any]
CompositeDecorators = List[Decorator]
# The whole content state. blocks and entity_map.
ContentState = Mapping[str, Any]
# Blocks have a predetermined set of keys and values, but lets be permissive without TypedDict.
Block = Mapping[str, Any]
# Entity key is int in blocks, str in Entity map.
EntityKey = str
# Entities have fixed keys.
EntityDetails = Mapping[str, Any]
EntityMap = Mapping[EntityKey, EntityDetails]

View File

@@ -0,0 +1,25 @@
from importlib import import_module
from typing import Any
def import_string(dotted_path: str) -> Any:
"""
Import a dotted module path and return the attribute/class designated by the
last name in the path. Raise ImportError if the import failed.
Taken from Django:
https://github.com/django/django/blob/f6bd00131e687aedf2719ad31e84b097562ca5f2/django/utils/module_loading.py#L7-L24
"""
try:
module_path, class_name = dotted_path.rsplit(".", 1)
except ValueError:
raise ImportError(f"{dotted_path} doesn't look like a module path")
module = import_module(module_path)
try:
return getattr(module, class_name)
except AttributeError:
raise ImportError(
f'Module "{module_path}" does not define a "{class_name}" attribute/class'
)

View File

@@ -0,0 +1,172 @@
from typing import List, Optional, Sequence, Union
from draftjs_exporter.constants import BLOCK_TYPES
from draftjs_exporter.dom import DOM
from draftjs_exporter.options import Options, OptionsMap
from draftjs_exporter.types import Block, Element, Props, RenderableType
class Wrapper:
"""
A wrapper is an element that wraps other nodes. It gets created
when the depth of a block is different than 0, so the DOM elements
have the appropriate amount of nesting.
"""
__slots__ = ("depth", "last_child", "type", "props", "elt")
def __init__(self, depth: int, options: Optional[Options] = None) -> None:
self.depth = depth
self.last_child = None
if options:
self.type = options.wrapper
self.props = options.wrapper_props
wrapper_props = dict(self.props) if self.props else {}
wrapper_props["block"] = {"type": options.type, "depth": depth}
self.elt = DOM.create_element(self.type, wrapper_props)
else:
self.type = None
self.props = None
self.elt = DOM.create_element()
def is_different(
self, depth: int, type_: RenderableType, props: Optional[Props]
) -> bool:
return depth > self.depth or type_ != self.type or props != self.props
class WrapperStack:
"""
Stack data structure for element wrappers.
The bottom of the stack contains the elements closest to the page body.
The top of the stack contains the most nested nodes.
"""
__slots__ = "stack"
def __init__(self) -> None:
self.stack: List[Wrapper] = []
def __str__(self) -> str:
return str(self.stack)
def length(self) -> int:
return len(self.stack)
def append(self, wrapper: Wrapper) -> None:
return self.stack.append(wrapper)
def get(self, index: int) -> Wrapper:
return self.stack[index]
def slice(self, length: int) -> None:
self.stack = self.stack[:length]
def head(self) -> Wrapper:
if self.length() > 0:
wrapper = self.stack[-1]
else:
wrapper = Wrapper(-1)
return wrapper
def tail(self) -> Wrapper:
return self.stack[0]
class WrapperState:
"""
This class does the initial node building for the tree.
It sets elements with the right tag, text content, and props.
It adds a wrapper element around elements, if required.
"""
__slots__ = ("block_options", "blocks", "stack")
def __init__(
self, block_options: OptionsMap, blocks: Sequence[Block]
) -> None:
self.block_options = block_options
self.blocks = blocks
self.stack = WrapperStack()
def __str__(self) -> str:
return f"<WrapperState: {self.stack}>"
def element_for(
self, block: Block, block_content: Union[Element, Sequence[Element]]
) -> Element:
type_ = block["type"] if "type" in block else "unstyled"
depth = block["depth"] if "depth" in block else 0
options = Options.get(self.block_options, type_, BLOCK_TYPES.FALLBACK)
props = dict(options.props)
props["block"] = block
props["blocks"] = self.blocks
# Make an element from the options specified in the block map.
elt = DOM.create_element(options.element, props, block_content)
parent = self.parent_for(options, depth, elt)
return parent
def parent_for(self, options: Options, depth: int, elt: Element) -> Element:
if options.wrapper:
parent = self.get_wrapper_elt(options, depth)
DOM.append_child(parent, elt)
self.stack.stack[-1].last_child = elt
else:
# Reset the stack if there is no wrapper.
if self.stack.length() > 0:
self.stack = WrapperStack()
parent = elt
return parent
def get_wrapper_elt(self, options: Options, depth: int) -> Element:
head = self.stack.head()
if head.is_different(depth, options.wrapper, options.wrapper_props):
self.update_stack(options, depth)
# If depth is lower than the maximum, we cut the stack.
if depth < head.depth:
self.stack.slice(depth + 1)
return self.stack.get(depth).elt
def update_stack(self, options: Options, depth: int) -> None:
if depth >= self.stack.length():
# If the depth is gte the stack length, we need more wrappers.
depth_levels = range(self.stack.length(), depth + 1)
for level in depth_levels:
new_wrapper = Wrapper(level, options)
# Determine where to append the new wrapper.
if self.stack.head().last_child is None:
# If there is no content in the current wrapper, we need
# to add an intermediary node.
props = dict(options.props)
props["block"] = {
"type": options.type,
"depth": depth,
"data": {},
}
props["blocks"] = self.blocks
wrapper_parent = DOM.create_element(options.element, props)
DOM.append_child(self.stack.head().elt, wrapper_parent)
else:
# Otherwise we can append at the end of the last child.
wrapper_parent = self.stack.head().last_child
DOM.append_child(wrapper_parent, new_wrapper.elt)
self.stack.append(new_wrapper)
else:
# Cut the stack to where it now stops, and add new wrapper.
self.stack.slice(depth)
self.stack.append(Wrapper(depth, options))