tag so we can give our own attributes.
446 | inner = remove_outer_div(inner)
447 |
448 | html = f"
"
449 |
450 | # Reset layout in case it was changed
451 | self.content.update_layout(self._original_layout)
452 |
453 | return html
454 |
455 |
456 | def remove_outer_div(html: str) -> str:
457 | """Remove outer
tags."""
458 | html = html.replace("
", "", 1)
459 | html = "".join(html.rsplit("
", 1))
460 | return html
461 |
462 |
463 | def image_to_bytes(image: Union[str, Path, BytesIO, "PILImage"]) -> BytesIO:
464 | """Convert `image` to bytes.
465 |
466 | Args:
467 | image (Union[str, Path, BytesIO, PIL.Image]): image object.
468 |
469 | Raises:
470 | TypeError: image type not recognised.
471 |
472 | Returns:
473 | BytesIO: image as bytes object.
474 |
475 | """
476 | if isinstance(image, BytesIO):
477 | return image
478 | elif _OptionalDependencies.PIL and isinstance(image, PILImage):
479 | return BytesIO(image.tobytes())
480 | elif isinstance(image, (str, Path)):
481 | return BytesIO(Path(image).read_bytes())
482 | else:
483 | raise TypeError(type(image))
484 |
485 |
486 | def bytes_to_base64(bytes: BytesIO) -> str:
487 | """
488 | Convert an image from bytes to base64 representation.
489 |
490 | Args:
491 | image (BytesIO): image bytes object.
492 |
493 | Returns:
494 | str: image encoded as a base64 utf-8 string.
495 |
496 | """
497 | return base64.b64encode(bytes.getvalue()).decode("utf-8")
498 |
499 |
500 | def table_of_contents(
501 | object: AbstractLayout, max_depth: Optional[int] = None, numbered: bool = True
502 | ) -> "Markdown":
503 | """Produce table of contents for a Layout object.
504 |
505 | Args:
506 | object (Layout): Target object for TOC.
507 | max_depth (int): Maximum depth of returned TOC.
508 | numbered (bool): If True TOC items are numbered.
509 | If False, bulletpoints are used.
510 |
511 | """
512 | from esparto.design.content import Markdown
513 |
514 | max_depth = max_depth or 99
515 |
516 | TOCItem = namedtuple("TOCItem", "title, level, id")
517 |
518 | def get_toc_items(parent: AbstractLayout) -> List[TOCItem]:
519 | def find_ids(parent: Any, level: int, acc: List[TOCItem]) -> List[TOCItem]:
520 | if hasattr(parent, "get_title_identifier") and parent.title:
521 | acc.append(TOCItem(parent.title, level, parent.get_title_identifier()))
522 | level += 1
523 | if hasattr(parent, "children"):
524 | for child in parent.children:
525 | find_ids(child, level, acc)
526 | else:
527 | return acc
528 | return acc
529 |
530 | acc_new = find_ids(parent, 0, [])
531 | return acc_new
532 |
533 | toc_items = get_toc_items(object)
534 |
535 | tab = "\t"
536 | marker = "1." if numbered else "*"
537 | markdown_list = [
538 | f"{(item.level - 1) * tab} {marker} [{item.title}](#{item.id})"
539 | for item in toc_items
540 | if item.level > 0 and item.level <= max_depth
541 | ]
542 | markdown_str = "\n".join(markdown_list)
543 |
544 | return Markdown(markdown_str)
545 |
546 |
547 | def responsive_svg_mpl(
548 | source: str, width: Optional[int] = None, height: Optional[int] = None
549 | ) -> str:
550 | """Make SVG element responsive."""
551 |
552 | regex_w = r"width=\S*"
553 | regex_h = r"height=\S*"
554 |
555 | width_ = f"width='{width}px'" if width else ""
556 | height_ = f"height='{height}px'" if height else ""
557 |
558 | source = re.sub(regex_w, width_, source, count=1)
559 | source = re.sub(regex_h, height_, source, count=1)
560 |
561 | # Preserve aspect ratio of SVG
562 | old_str = r"
2 |
--------------------------------------------------------------------------------
/esparto/design/layout.py:
--------------------------------------------------------------------------------
1 | """Layout classes for defining page appearance and structure."""
2 |
3 | import copy
4 | import re
5 | from abc import ABC
6 | from pprint import pformat
7 | from typing import (
8 | Any,
9 | Callable,
10 | Dict,
11 | Iterable,
12 | Iterator,
13 | List,
14 | Optional,
15 | Set,
16 | Type,
17 | TypeVar,
18 | Union,
19 | )
20 |
21 | import bs4 # type: ignore
22 |
23 | from esparto._options import OutputOptions, options, options_context
24 | from esparto.design.base import AbstractLayout, Child
25 | from esparto.publish.output import nb_display, publish_html, publish_pdf
26 |
27 | T = TypeVar("T", bound="Layout")
28 |
29 |
30 | class Layout(AbstractLayout, ABC):
31 | """Class Template for Layout elements.
32 |
33 | Layout class hierarchy:
34 | `Page -> Section -> Row -> Column -> Content`
35 |
36 | Attributes:
37 | title (str): Object title. Used as a title within the page and as a key value.
38 | children (list): Child items defining the page layout and content.
39 | title_classes (list): Additional CSS classes to apply to title.
40 | title_styles (dict): Additional CSS styles to apply to title.
41 | body_classes (list): Additional CSS classes to apply to body.
42 | body_styles (dict): Additional CSS styles to apply to body.
43 |
44 | """
45 |
46 | # ------------------------------------------------------------------------+
47 | # Magic Methods |
48 | # ------------------------------------------------------------------------+
49 |
50 | title: Optional[str]
51 | children: List[Child] = []
52 |
53 | title_html_tag: str
54 | title_classes: List[str]
55 | title_styles: Dict[str, Any]
56 |
57 | body_html_tag: str
58 | body_classes: List[str]
59 | body_styles: Dict[str, Any]
60 |
61 | @property
62 | def _default_id(self) -> str:
63 | return f"es-{type(self).__name__}".lower()
64 |
65 | @property
66 | def _parent_class(self) -> Type["Layout"]:
67 | raise NotImplementedError
68 |
69 | @property
70 | def _child_class(self) -> Type["Layout"]:
71 | raise NotImplementedError
72 |
73 | _dependencies = {"bootstrap"}
74 |
75 | @property
76 | def _child_ids(self) -> Dict[str, str]:
77 | """Return existing child IDs or a new dict."""
78 | try:
79 | super().__getattribute__("__child_ids")
80 | except AttributeError:
81 | super().__setattr__("__child_ids", {})
82 | child_ids: Dict[str, str] = super().__getattribute__("__child_ids")
83 | return child_ids
84 |
85 | def __init__(
86 | self,
87 | title: Optional[str] = None,
88 | children: Union[List[Child], Child] = None,
89 | title_classes: Optional[List[str]] = None,
90 | title_styles: Optional[Dict[str, Any]] = None,
91 | body_classes: Optional[List[str]] = None,
92 | body_styles: Optional[Dict[str, Any]] = None,
93 | ):
94 | self.title = title
95 | children = children or []
96 | self.set_children(children)
97 |
98 | self.__post_init__()
99 |
100 | title_classes = title_classes or []
101 | title_styles = title_styles or {}
102 | body_classes = body_classes or []
103 | body_styles = body_styles or {}
104 |
105 | self.title_classes += title_classes
106 | self.title_styles.update(title_styles)
107 |
108 | self.body_classes += body_classes
109 | self.body_styles.update(body_styles)
110 |
111 | def __post_init__(self) -> None:
112 | raise NotImplementedError
113 |
114 | def __iter__(self) -> Iterator["Layout"]:
115 | return iter([self])
116 |
117 | def __repr__(self) -> str:
118 | return self._tree()
119 |
120 | def _repr_html_(self) -> None:
121 | self.display()
122 |
123 | def __str__(self) -> str:
124 | return self._tree()
125 |
126 | def __add__(self: T, other: Child) -> T:
127 | new = copy.copy(self)
128 | new.children = self.children + [*self._smart_wrap(other)]
129 | return new
130 |
131 | def __eq__(self, other: Any) -> bool:
132 | if isinstance(other, self.__class__):
133 | return (
134 | self.title == other.title
135 | and len(self.children) == len(other.children)
136 | and all((x == y for x, y in zip(self.children, other.children)))
137 | )
138 | return False
139 |
140 | def __ne__(self, other: Any) -> bool:
141 | return not self.__eq__(other)
142 |
143 | def __getattribute__(self, key: str) -> Any:
144 | child_id = super().__getattribute__("_child_ids").get(key)
145 | if child_id:
146 | return self.__getitem__(child_id)
147 | return super().__getattribute__(key)
148 |
149 | def __setattr__(self, key: str, value: Any) -> None:
150 | child_id = super().__getattribute__("_child_ids").get(key)
151 | if child_id:
152 | self.__setitem__(child_id, value)
153 | else:
154 | super().__setattr__(key, value)
155 |
156 | def __delattr__(self, key: str) -> None:
157 | child_id = super().__getattribute__("_child_ids").get(key)
158 | if child_id:
159 | self.__delitem__(child_id)
160 | else:
161 | super().__delattr__(key)
162 |
163 | def __getitem__(self, key: Union[str, int]) -> Any:
164 | if isinstance(key, str):
165 | indexes = get_matching_titles(key, self.children)
166 | if len(indexes) and key:
167 | return self.children[indexes[0]]
168 | value = self._child_class(title=key)
169 | self.children.append(value)
170 | if key:
171 | self._add_child_id(key)
172 | return self.children[-1]
173 |
174 | elif isinstance(key, int):
175 | if key < len(self.children):
176 | return self.children[key]
177 | value = self._child_class()
178 | self.children.append(value)
179 | return self.children[-1]
180 |
181 | raise KeyError(key)
182 |
183 | def __setitem__(self, key: Union[str, int], value: Any) -> None:
184 | value = copy.copy(value)
185 | title = (
186 | getattr(value, "title", None) if issubclass(type(value), Layout) else None
187 | )
188 | if not isinstance(value, self._child_class):
189 | if issubclass(self._child_class, Column):
190 | value = self._child_class(title=title, children=[value])
191 | else:
192 | value = self._smart_wrap(value)
193 | value = value[0]
194 | if isinstance(key, str):
195 | if key:
196 | value.title = title or key
197 | indexes = get_matching_titles(key, self.children)
198 | if indexes:
199 | self.children[indexes[0]] = value
200 | else:
201 | self.children.append(value)
202 | self._add_child_id(value.title)
203 | else:
204 | self.children.append(value)
205 | return
206 | elif isinstance(key, int):
207 | if key < len(self.children):
208 | value.title = title or getattr(self.children[key], "title", None)
209 | self.children[key] = value
210 | return
211 | self.children.append(value)
212 | return
213 |
214 | raise KeyError(key)
215 |
216 | def __delitem__(self, key: Union[int, str]) -> None:
217 | if isinstance(key, str):
218 | indexes = get_matching_titles(key, self.children)
219 | if len(indexes):
220 | self._remove_child_id(key)
221 | del self.children[indexes[0]]
222 | return None
223 | elif isinstance(key, int) and key < len(self.children):
224 | child_title = getattr(self.children[key], "title", None)
225 | if child_title:
226 | self._remove_child_id(child_title)
227 | del self.children[key]
228 | return None
229 | raise KeyError(key)
230 |
231 | def __lshift__(self, other: Child) -> Child:
232 | self.set_children(other)
233 | return other
234 |
235 | def __rshift__(self, other: Child) -> "Layout":
236 | self.set_children(other)
237 | return self
238 |
239 | def __copy__(self) -> "Layout":
240 | attributes = vars(self)
241 | new = self.__class__()
242 | new.__dict__.update(attributes)
243 | new.children = [*new.children]
244 | return new
245 |
246 | # ------------------------------------------------------------------------+
247 | # Public Methods |
248 | # ------------------------------------------------------------------------+
249 |
250 | def display(self) -> None:
251 | """Render content in a Notebook environment."""
252 | nb_display(self)
253 |
254 | def get_identifier(self) -> str:
255 | """Get the HTML element ID for the current object."""
256 | return clean_attr_name(str(self.title)) if self.title else self._default_id
257 |
258 | def get_title_identifier(self) -> str:
259 | """Get the HTML element ID for the current object title."""
260 | return f"{self.get_identifier()}-title"
261 |
262 | def set_children(self, other: Union[List[Child], Child]) -> None:
263 | """Set children as `other`."""
264 | other = copy.copy(other)
265 | self.children = [*self._smart_wrap(other)]
266 | for child in self.children:
267 | title = getattr(child, "title", None)
268 | if title:
269 | self._add_child_id(title)
270 |
271 | def to_html(self, **kwargs: bool) -> str:
272 | """Render object as HTML string.
273 |
274 | Returns:
275 | html (str): HTML string.
276 |
277 | """
278 | children_rendered = " ".join([c.to_html(**kwargs) for c in self.children])
279 | title_rendered = (
280 | render_html(
281 | self.title_html_tag,
282 | self.title_classes,
283 | self.title_styles,
284 | self.title,
285 | self.get_title_identifier(),
286 | )
287 | if self.title
288 | else ""
289 | )
290 | html = render_html(
291 | self.body_html_tag,
292 | self.body_classes,
293 | self.body_styles,
294 | f"{title_rendered}\n{children_rendered}\n",
295 | self.get_identifier(),
296 | )
297 | html = bs4.BeautifulSoup(html, "html.parser").prettify()
298 | return html
299 |
300 | def tree(self) -> None:
301 | """Display page tree."""
302 | print(self._tree())
303 |
304 | # ------------------------------------------------------------------------+
305 | # Private Methods |
306 | # ------------------------------------------------------------------------+
307 |
308 | def _add_child_id(self, key: str) -> None:
309 | attr_name = clean_attr_name(key)
310 | if attr_name:
311 | self._child_ids[attr_name] = key
312 | super().__setattr__(attr_name, self[key])
313 |
314 | def _remove_child_id(self, key: str) -> None:
315 | attr_name = clean_attr_name(key)
316 | if attr_name in self._child_ids:
317 | del self._child_ids[attr_name]
318 | super().__delattr__(attr_name)
319 |
320 | def _smart_wrap(self, child_list: Union[List[Child], Child]) -> List[Child]:
321 | """Wrap children in a coherent class hierarchy.
322 |
323 | Args:
324 | children: Sequence of Content and / or Child items.
325 |
326 | Returns:
327 | List of Layout and Content items wrapped in a coherent class hierarchy.
328 |
329 |
330 | If the parent object is a Column and the item is a Content Class:
331 | - return child with no modification
332 | If the parent object is a Column and the item is not a Content Class:
333 | - cast the child to an appropriate Content Class if possible
334 | - return the child
335 | If the current item is wrapped and unwrapped items have been accumulated:
336 | - wrap the unwrapped children
337 | - append newly wrapped to output
338 | - append current child to output
339 | If the current child is wrapped and we have no accumulated unwrapped items:
340 | - append the wrapped child to output
341 | If the current child is a dict and the parent is a Row:
342 | - use the dictionary key as a title and value as content
343 | - wrap and append the current child to output
344 | If the current child is unwrapped and the parent is a Row:
345 | - wrap and append the current child to output
346 | If the current item is unwrapped and the parent is not a Row:
347 | - add the current child to unwrapped item accumulator
348 | Finally:
349 | - wrap any accumulated unwrapped items
350 | - append the final wrapped segment to output
351 |
352 | """
353 | return smart_wrap(self, child_list)
354 |
355 | def _recurse_children(self, idx: int) -> Dict[str, Any]:
356 | key = self.title or f"{type(self).__name__} {idx}"
357 | tree = {
358 | f"{key}": [
359 | child._recurse_children(idx) # type: ignore
360 | if hasattr(child, "_recurse_children")
361 | else str(child)
362 | for idx, child in enumerate(self.children)
363 | ]
364 | }
365 | return tree
366 |
367 | def _required_dependencies(self) -> Set[str]:
368 | deps: Set[str] = self._dependencies
369 |
370 | def dep_finder(parent: Any) -> None:
371 | nonlocal deps
372 | for child in parent.children:
373 | deps = deps | set(getattr(child, "_dependencies", {}))
374 | if hasattr(child, "children"):
375 | dep_finder(child)
376 |
377 | dep_finder(self)
378 | return deps
379 |
380 | def _tree(self) -> str:
381 | return pformat(self._recurse_children(idx=0))
382 |
383 | def _ipython_key_completions_(self) -> List[str]: # pragma: no cover
384 | return [
385 | getattr(child, "title")
386 | for child in self.children
387 | if hasattr(child, "title")
388 | ]
389 |
390 |
391 | class Page(Layout):
392 | """Layout class that defines a Page.
393 |
394 | Args:
395 | title (str): Used as a title within the page and as a key value.
396 | navbrand (str): Brand name. Displayed in the page navbar if provided.
397 | table_of_contents (bool, int): Add a Table of Contents to the top of page.
398 | Passing an `int` will define the maximum depth.
399 | max_width (int): Maximum page width expressed in pixels.
400 | output_options (es.OutputOptions): Page specific rendering and output options.
401 | children (list): Child items defining layout and content.
402 | title_classes (list): Additional CSS classes to apply to title.
403 | title_styles (dict): Additional CSS styles to apply to title.
404 | body_classes (list): Additional CSS classes to apply to body.
405 | body_styles (dict): Additional CSS styles to apply to body.
406 |
407 | """
408 |
409 | output_options: OutputOptions = options
410 |
411 | def __init__(
412 | self,
413 | title: Optional[str] = None,
414 | navbrand: Optional[str] = "",
415 | table_of_contents: Union[bool, int] = False,
416 | max_width: int = 800,
417 | output_options: Optional[OutputOptions] = None,
418 | children: Union[List[Child], Child] = None,
419 | title_classes: Optional[List[str]] = None,
420 | title_styles: Optional[Dict[str, Any]] = None,
421 | body_classes: Optional[List[str]] = None,
422 | body_styles: Optional[Dict[str, Any]] = None,
423 | ):
424 | super().__init__(
425 | title, children, title_classes, title_styles, body_classes, body_styles
426 | )
427 | self.navbrand = navbrand
428 | self.table_of_contents = table_of_contents
429 | self.max_width = max_width
430 | self.output_options = output_options or options
431 |
432 | def save(
433 | self,
434 | filepath: str = "./esparto-doc.html",
435 | return_html: bool = False,
436 | dependency_source: Optional[str] = None,
437 | ) -> Optional[str]:
438 | """
439 | Save page to HTML file.
440 |
441 | Note:
442 | Alias for `self.save_html()`.
443 |
444 | Args:
445 | filepath (str): Destination filepath.
446 | return_html (bool): If True, return HTML as a string.
447 | dependency_source (str): 'cdn' or 'inline'.
448 |
449 | Returns:
450 | html (str): Document rendered as HTML. (If `return_html` is True)
451 |
452 | """
453 | html = self.save_html(
454 | filepath=filepath,
455 | return_html=return_html,
456 | dependency_source=dependency_source,
457 | )
458 |
459 | if return_html:
460 | return html
461 | return None
462 |
463 | @options_context(output_options)
464 | def save_html(
465 | self,
466 | filepath: str = "./esparto-doc.html",
467 | return_html: bool = False,
468 | dependency_source: Optional[str] = None,
469 | ) -> Optional[str]:
470 | """
471 | Save page to HTML file.
472 |
473 | Args:
474 | filepath (str): Destination filepath.
475 | return_html (bool): If True, return HTML as a string.
476 | dependency_source (str): 'cdn' or 'inline'.
477 |
478 | Returns:
479 | html (str): Document rendered as HTML. (If `return_html` is True)
480 |
481 | """
482 | html = publish_html(
483 | self,
484 | filepath=filepath,
485 | return_html=return_html,
486 | dependency_source=dependency_source,
487 | )
488 | if return_html:
489 | return html
490 | return None
491 |
492 | @options_context(output_options)
493 | def save_pdf(
494 | self, filepath: str = "./esparto-doc.pdf", return_html: bool = False
495 | ) -> Optional[str]:
496 | """
497 | Save page to PDF file.
498 |
499 | Note:
500 | Requires `weasyprint` library.
501 |
502 | Args:
503 | filepath (str): Destination filepath.
504 | return_html (bool): If True, return intermediate HTML representation as a string.
505 |
506 | Returns:
507 | html (str): Document rendered as HTML. (If `return_html` is True)
508 |
509 | """
510 | html = publish_pdf(self, filepath, return_html=return_html)
511 | if return_html:
512 | return html
513 | return None
514 |
515 | @options_context(output_options)
516 | def to_html(self, **kwargs: bool) -> str:
517 | if self.table_of_contents:
518 | # Create a copy of the page and dynamically generate the TOC.
519 | # Copy is required so that TOC is not added multiple times and
520 | # always reflects the current content.
521 | from esparto.design.content import table_of_contents
522 |
523 | max_depth = (
524 | None if self.table_of_contents is True else self.table_of_contents
525 | )
526 | page_copy = copy.copy(self)
527 | toc = table_of_contents(page_copy, max_depth=max_depth)
528 | page_copy.children.insert(
529 | 0,
530 | page_copy._child_class(
531 | title="Contents", children=[toc], title_classes=["h4"]
532 | ),
533 | )
534 | page_copy.table_of_contents = False
535 | return page_copy.to_html(**kwargs)
536 |
537 | self.body_styles.update({"max-width": f"{self.max_width}px"})
538 |
539 | return super().to_html(**kwargs)
540 |
541 | def __post_init__(self) -> None:
542 | self.title_html_tag = "h1"
543 | self.title_classes = ["es-page-title"]
544 | self.title_styles = {}
545 |
546 | self.body_html_tag = "article"
547 | self.body_classes = ["es-page-body"]
548 | self.body_styles = {}
549 |
550 | @property
551 | def _parent_class(self) -> Type["Layout"]:
552 | return Page
553 |
554 | @property
555 | def _child_class(self) -> Type["Layout"]:
556 | return Section
557 |
558 |
559 | class Section(Layout):
560 | """Layout class that defines a Section.
561 |
562 | Args:
563 | title (str): Used as a title within the page and as a key value.
564 | children (list): Child items defining layout and content.
565 | title_classes (list): Additional CSS classes to apply to title.
566 | title_styles (dict): Additional CSS styles to apply to title.
567 | body_classes (list): Additional CSS classes to apply to body.
568 | body_styles (dict): Additional CSS styles to apply to body.
569 |
570 | """
571 |
572 | def __post_init__(self) -> None:
573 | self.title_html_tag = "h3"
574 | self.title_classes = ["es-section-title"]
575 | self.title_styles = {}
576 |
577 | self.body_html_tag = "section"
578 | self.body_classes = ["es-section-body"]
579 | self.body_styles = {}
580 |
581 | @property
582 | def _parent_class(self) -> Type["Layout"]:
583 | return Page
584 |
585 | @property
586 | def _child_class(self) -> Type["Layout"]:
587 | return Row
588 |
589 |
590 | class CardSection(Section):
591 | """Layout class that defines a CardSection. CardSections wrap content in Cards by default.
592 |
593 | Args:
594 | title (str): Used as a title within the page and as a key value.
595 | children (list): Child items defining layout and content.
596 | cards_equal (bool): Cards in the same Row are stretched vertically if True.
597 | title_classes (list): Additional CSS classes to apply to title.
598 | title_styles (dict): Additional CSS styles to apply to title.
599 | body_classes (list): Additional CSS classes to apply to body.
600 | body_styles (dict): Additional CSS styles to apply to body.
601 |
602 | """
603 |
604 | def __init__(
605 | self,
606 | title: Optional[str] = None,
607 | children: Union[List[Child], Child, None] = None,
608 | cards_equal: bool = False,
609 | title_classes: Optional[List[str]] = None,
610 | title_styles: Optional[Dict[str, Any]] = None,
611 | body_classes: Optional[List[str]] = None,
612 | body_styles: Optional[Dict[str, Any]] = None,
613 | ):
614 | super().__init__(
615 | title=title,
616 | children=children,
617 | title_classes=title_classes,
618 | title_styles=title_styles,
619 | body_classes=body_classes,
620 | body_styles=body_styles,
621 | )
622 |
623 | self.cards_equal = cards_equal
624 |
625 | @property
626 | def _child_class(self) -> Type["Layout"]:
627 | # Attribute missing if class is not instantiated
628 | if hasattr(self, "cards_equal") and self.cards_equal:
629 | return CardRowEqual
630 | return CardRow
631 |
632 |
633 | class Row(Layout):
634 | """Layout class that defines a Row.
635 |
636 | Args:
637 | title (str): Used as a title within the page and as a key value.
638 | children (list): Child items defining layout and content.
639 | title_classes (list): Additional CSS classes to apply to title.
640 | title_styles (dict): Additional CSS styles to apply to title.
641 | body_classes (list): Additional CSS classes to apply to body.
642 | body_styles (dict): Additional CSS styles to apply to body.
643 |
644 | """
645 |
646 | def __post_init__(self) -> None:
647 | self.title_html_tag = "h5"
648 | self.title_classes = ["col-12", "es-row-title"]
649 | self.title_styles = {}
650 |
651 | self.body_html_tag = "div"
652 | self.body_classes = ["row", "es-row-body"]
653 | self.body_styles = {}
654 |
655 | @property
656 | def _parent_class(self) -> Type["Layout"]:
657 | return Section
658 |
659 | @property
660 | def _child_class(self) -> Type["Layout"]:
661 | return Column
662 |
663 | def __setitem__(self, key: Union[str, int], value: Any) -> None:
664 | if isinstance(value, dict):
665 | title, content = list(value.items())[0]
666 | value = self._child_class(title=title, children=[content])
667 | super().__setitem__(key, value)
668 |
669 |
670 | class Column(Layout):
671 | """Layout class that defines a Column.
672 |
673 | Args:
674 | title (str): Used as a title within the page and as a key value.
675 | children (list): Child items defining layout and content.
676 | col_width (int): Fix column width - must be between 1 and 12.
677 | title_classes (list): Additional CSS classes to apply to title.
678 | title_styles (dict): Additional CSS styles to apply to title.
679 | body_classes (list): Additional CSS classes to apply to body.
680 | body_styles (dict): Additional CSS styles to apply to body.
681 |
682 | """
683 |
684 | def __init__(
685 | self,
686 | title: Optional[str] = None,
687 | children: Union[List[Child], Child] = None,
688 | col_width: Optional[int] = None,
689 | title_classes: Optional[List[str]] = None,
690 | title_styles: Optional[Dict[str, Any]] = None,
691 | body_classes: Optional[List[str]] = None,
692 | body_styles: Optional[Dict[str, Any]] = None,
693 | ):
694 | self.title = title
695 | children = children or []
696 | self.set_children(children)
697 | self.col_width = col_width
698 |
699 | self.__post_init__()
700 |
701 | title_classes = title_classes or []
702 | title_styles = title_styles or {}
703 | body_classes = body_classes or []
704 | body_styles = body_styles or {}
705 |
706 | self.title_classes += title_classes
707 | self.title_styles.update(title_styles)
708 |
709 | self.body_classes += body_classes
710 | self.body_styles.update(body_styles)
711 |
712 | def __post_init__(self) -> None:
713 | self.title_html_tag = "h5"
714 | self.title_classes = ["es-column-title"]
715 | self.title_styles = {}
716 |
717 | col_class = f"col-lg-{self.col_width}" if self.col_width else "col-lg"
718 | self.body_html_tag = "div"
719 | self.body_classes = [col_class, "es-column-body"]
720 | self.body_styles = {}
721 |
722 | @property
723 | def _parent_class(self) -> Type["Layout"]:
724 | return Row
725 |
726 | @property
727 | def _child_class(self) -> Type["Layout"]:
728 | raise NotImplementedError
729 |
730 |
731 | class CardRow(Row):
732 | """Layout class that defines a CardRow. CardRows wrap content in Cards by default.
733 |
734 | Args:
735 | title (str): Used as a title within the page and as a key value.
736 | children (list): Child items defining layout and content.
737 | title_classes (list): Additional CSS classes to apply to title.
738 | title_styles (dict): Additional CSS styles to apply to title.
739 | body_classes (list): Additional CSS classes to apply to body.
740 | body_styles (dict): Additional CSS styles to apply to body.
741 |
742 | """
743 |
744 | @property
745 | def _child_class(self) -> Type["Layout"]:
746 | return Card
747 |
748 |
749 | class CardRowEqual(CardRow):
750 | """Layout class that defines a CardRow with Cards of equal height.
751 |
752 | Args:
753 | title (str): Used as a title within the page and as a key value.
754 | children (list): Child items defining layout and content.
755 | title_classes (list): Additional CSS classes to apply to title.
756 | title_styles (dict): Additional CSS styles to apply to title.
757 | body_classes (list): Additional CSS classes to apply to body.
758 | body_styles (dict): Additional CSS styles to apply to body.
759 |
760 | """
761 |
762 | def __post_init__(self) -> None:
763 | super().__post_init__()
764 | self.body_styles = {"align-items": "stretch"}
765 |
766 |
767 | class Card(Column):
768 | """Layout class that defines a Card.
769 |
770 | Child items will be vertically stacked by default.
771 | Horizontal arrangement is achieved by nesting content inside a Row.
772 |
773 | Args:
774 | title (str): Used as a title within the page and as a key value.
775 | children (list): Child items defining layout and content.
776 | col_width (int): Fix column width - must be between 1 and 12.
777 | title_classes (list): Additional CSS classes to apply to title.
778 | title_styles (dict): Additional CSS styles to apply to title.
779 | body_classes (list): Additional CSS classes to apply to body.
780 | body_styles (dict): Additional CSS styles to apply to body.
781 |
782 | """
783 |
784 | def __init__(
785 | self,
786 | title: Optional[str] = None,
787 | children: Union[List[Child], Child] = None,
788 | col_width: int = 6,
789 | title_classes: Optional[List[str]] = None,
790 | title_styles: Optional[Dict[str, Any]] = None,
791 | body_classes: Optional[List[str]] = None,
792 | body_styles: Optional[Dict[str, Any]] = None,
793 | ):
794 | super().__init__(
795 | title=title,
796 | children=children,
797 | col_width=col_width,
798 | title_classes=title_classes,
799 | title_styles=title_styles,
800 | body_classes=body_classes,
801 | body_styles=body_styles,
802 | )
803 |
804 | def __post_init__(self) -> None:
805 | self.title_html_tag = "h5"
806 | self.title_classes = ["card-title", "es-card-title"]
807 | self.title_styles = {}
808 |
809 | self.body_html_tag = "div"
810 |
811 | col_class = f"col-lg-{self.col_width}" if self.col_width else "col-lg"
812 | self.body_classes = [col_class, "es-card"]
813 | self.body_styles = {}
814 |
815 | def to_html(self, **kwargs: bool) -> str:
816 | """Render content to HTML string.
817 |
818 | Returns:
819 | html (str): HTML string.
820 |
821 | """
822 | children_rendered = " ".join([c.to_html(**kwargs) for c in self.children])
823 | title_rendered = (
824 | render_html(
825 | self.title_html_tag,
826 | self.title_classes,
827 | self.title_styles,
828 | self.title,
829 | self.get_title_identifier(),
830 | )
831 | if self.title
832 | else ""
833 | )
834 | card_body_classes = ["es-card-body"]
835 | card_body_styles: Dict[str, str] = {}
836 | html_body = render_html(
837 | "div",
838 | card_body_classes,
839 | card_body_styles,
840 | f"\n{title_rendered}\n{children_rendered}\n",
841 | f"{self.get_identifier()}-body",
842 | )
843 | html_full = render_html(
844 | self.body_html_tag,
845 | self.body_classes,
846 | self.body_styles,
847 | f"\n{html_body}\n",
848 | f"{self.get_identifier()}",
849 | )
850 |
851 | return html_full
852 |
853 |
854 | class Spacer(Column):
855 | """Empty Column for making space within a Row."""
856 |
857 |
858 | class PageBreak(Section):
859 | """Defines a page break when printing or saving to PDF."""
860 |
861 | body_id = "es-page-break"
862 |
863 | def __post_init__(self) -> None:
864 | self.title_html_tag = ""
865 | self.title_classes = []
866 | self.title_styles = {}
867 |
868 | self.body_html_tag = "div"
869 | self.body_classes = []
870 | self.body_styles = {}
871 |
872 |
873 | def smart_wrap(self: Layout, child_list: Union[List[Child], Child]) -> List[Child]:
874 | from esparto.design.adaptors import content_adaptor
875 |
876 | child_list = ensure_iterable(child_list)
877 |
878 | if isinstance(self, Column):
879 | if any((isinstance(x, dict) for x in child_list)):
880 | raise TypeError("Invalid content passed to Column: 'dict'")
881 | return [content_adaptor(x) for x in child_list]
882 |
883 | is_row = isinstance(self, Row)
884 | unwrapped_acc: List[Child] = []
885 | output: List[Child] = []
886 |
887 | for child in child_list:
888 | is_wrapped = isinstance(child, self._child_class)
889 |
890 | if is_wrapped:
891 | if unwrapped_acc:
892 | wrapped_segment = self._child_class(children=unwrapped_acc)
893 | output.append(wrapped_segment)
894 | output.append(child)
895 | unwrapped_acc = []
896 | else:
897 | output.append(child)
898 | else: # if not is_wrapped
899 | if is_row:
900 | if isinstance(child, dict):
901 | title, child = list(child.items())[0]
902 | else:
903 | title = None
904 | output.append(self._child_class(title=title, children=[child]))
905 | else:
906 | unwrapped_acc.append(child)
907 |
908 | if unwrapped_acc:
909 | wrapped_segment = self._child_class(children=unwrapped_acc)
910 | output.append(wrapped_segment)
911 |
912 | return output
913 |
914 |
915 | def render_html(
916 | tag: str,
917 | classes: List[str],
918 | styles: Dict[str, str],
919 | children: str,
920 | identifier: Optional[str] = None,
921 | ) -> str:
922 | """Render HTML from provided attributes."""
923 | class_str = " ".join(classes) if classes else ""
924 | class_str = f"class='{class_str}'" if classes else ""
925 |
926 | style_str = "; ".join((f"{key}: {value}" for key, value in styles.items()))
927 | style_str = f"style='{style_str}'" if styles else ""
928 |
929 | id_str = f"id='{identifier}'" if identifier else ""
930 |
931 | rendered = " ".join((f"<{tag} {id_str} {class_str} {style_str}>").split())
932 | rendered += f"\n {children}\n{tag}>"
933 |
934 | return rendered
935 |
936 |
937 | def get_index_where(
938 | condition: Callable[..., bool], iterable: Iterable[Any]
939 | ) -> List[int]:
940 | """Return index values where `condition` is `True`."""
941 | return [idx for idx, item in enumerate(iterable) if condition(item)]
942 |
943 |
944 | def get_matching_titles(title: str, children: List["Child"]) -> List[int]:
945 | """Return child items with matching title."""
946 | return get_index_where(lambda x: bool(getattr(x, "title", None) == title), children)
947 |
948 |
949 | def clean_attr_name(attr_name: str) -> str:
950 | """Remove invalid characters from the attribute name."""
951 | if not attr_name:
952 | return ""
953 |
954 | # Remove leading and trailing spaces
955 | attr_name = attr_name.strip().replace(" ", "_").lower()
956 |
957 | # Remove invalid characters
958 | attr_name = re.sub("[^0-9a-zA-Z_]", "", attr_name)
959 |
960 | # Remove leading characters until we find a letter or underscore
961 | attr_name = re.sub("^[^a-zA-Z_]+", "", attr_name)
962 |
963 | return attr_name
964 |
965 |
966 | def ensure_iterable(something: Any) -> Iterable[Any]:
967 | # Convert any non-list iterators to lists
968 | iterable = (
969 | list(something) if isinstance(something, (list, tuple, set)) else [something]
970 | )
971 | # Un-nest any nested lists of children
972 | if len(list(iterable)) == 1 and isinstance(list(iterable)[0], (list, tuple, set)):
973 | iterable = list(iterable)[0]
974 | return iterable
975 |
--------------------------------------------------------------------------------