reportlab-json-renderer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. reportlab_json_renderer/__init__.py +56 -0
  2. reportlab_json_renderer/assets/__init__.py +1 -0
  3. reportlab_json_renderer/blocks/__init__.py +82 -0
  4. reportlab_json_renderer/blocks/badge.py +45 -0
  5. reportlab_json_renderer/blocks/base.py +61 -0
  6. reportlab_json_renderer/blocks/callout.py +75 -0
  7. reportlab_json_renderer/blocks/callout_group.py +56 -0
  8. reportlab_json_renderer/blocks/chart.py +68 -0
  9. reportlab_json_renderer/blocks/divider.py +45 -0
  10. reportlab_json_renderer/blocks/image.py +58 -0
  11. reportlab_json_renderer/blocks/insight_list.py +62 -0
  12. reportlab_json_renderer/blocks/kpi_grid.py +120 -0
  13. reportlab_json_renderer/blocks/layout.py +79 -0
  14. reportlab_json_renderer/blocks/matrix_table.py +107 -0
  15. reportlab_json_renderer/blocks/page_break.py +26 -0
  16. reportlab_json_renderer/blocks/paragraph.py +53 -0
  17. reportlab_json_renderer/blocks/recommendations.py +99 -0
  18. reportlab_json_renderer/blocks/registry.py +92 -0
  19. reportlab_json_renderer/blocks/rich_text.py +101 -0
  20. reportlab_json_renderer/blocks/section.py +64 -0
  21. reportlab_json_renderer/blocks/spacer.py +27 -0
  22. reportlab_json_renderer/blocks/summary_box.py +66 -0
  23. reportlab_json_renderer/blocks/table.py +133 -0
  24. reportlab_json_renderer/blocks/title.py +107 -0
  25. reportlab_json_renderer/cli.py +388 -0
  26. reportlab_json_renderer/renderer.py +348 -0
  27. reportlab_json_renderer/schema/__init__.py +19 -0
  28. reportlab_json_renderer/schema/base.py +387 -0
  29. reportlab_json_renderer/schema/constants.py +23 -0
  30. reportlab_json_renderer/schema/validators.py +286 -0
  31. reportlab_json_renderer/templates/__init__.py +31 -0
  32. reportlab_json_renderer/templates/analytics_report_v1.py +19 -0
  33. reportlab_json_renderer/templates/base.py +139 -0
  34. reportlab_json_renderer/templates/business_report_v1.py +19 -0
  35. reportlab_json_renderer/templates/compact_report_v1.py +19 -0
  36. reportlab_json_renderer/templates/invoice_v1.py +19 -0
  37. reportlab_json_renderer/templates/proposal_v1.py +19 -0
  38. reportlab_json_renderer/templates/registry.py +79 -0
  39. reportlab_json_renderer/themes/__init__.py +25 -0
  40. reportlab_json_renderer/themes/base.py +145 -0
  41. reportlab_json_renderer/themes/dark.py +30 -0
  42. reportlab_json_renderer/themes/green.py +27 -0
  43. reportlab_json_renderer/themes/neutral.py +28 -0
  44. reportlab_json_renderer/themes/registry.py +77 -0
  45. reportlab_json_renderer/utils/__init__.py +1 -0
  46. reportlab_json_renderer/utils/charts.py +489 -0
  47. reportlab_json_renderer/utils/colors.py +120 -0
  48. reportlab_json_renderer/utils/errors.py +36 -0
  49. reportlab_json_renderer/utils/fonts.py +77 -0
  50. reportlab_json_renderer/utils/images.py +199 -0
  51. reportlab_json_renderer/utils/text.py +131 -0
  52. reportlab_json_renderer/utils/units.py +82 -0
  53. reportlab_json_renderer-0.1.0.dist-info/METADATA +347 -0
  54. reportlab_json_renderer-0.1.0.dist-info/RECORD +57 -0
  55. reportlab_json_renderer-0.1.0.dist-info/WHEEL +4 -0
  56. reportlab_json_renderer-0.1.0.dist-info/entry_points.txt +2 -0
  57. reportlab_json_renderer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,56 @@
1
+ """reportlab-json-renderer: JSON-driven PDF generation over ReportLab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from reportlab_json_renderer.renderer import build_pdf
9
+
10
+ __version__ = "0.1.0"
11
+
12
+
13
+ def render_pdf(
14
+ spec: dict[str, Any],
15
+ output_path: str | None = None,
16
+ *,
17
+ allow_partial: bool = False,
18
+ asset_root: str | Path | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Render a PDF from a validated JSON specification.
21
+
22
+ Args:
23
+ spec: The JSON document specification conforming to the report schema.
24
+ output_path: Optional filesystem path for the generated PDF.
25
+ If ``None``, the PDF is returned as bytes only.
26
+ allow_partial: If ``True``, continue after block-level render errors and
27
+ return them as warnings. If ``False``, raise on the first block-level
28
+ render failure.
29
+ asset_root: Directory boundary for local assets such as images. Relative
30
+ image paths are resolved under this root, and traversal outside it is
31
+ rejected. Defaults to the current working directory.
32
+
33
+ Returns:
34
+ A result dictionary containing:
35
+ - ``success`` (bool): Whether rendering completed without errors.
36
+ - ``path`` (str | None): The output file path, if written.
37
+ - ``bytes`` (bytes | None): Raw PDF bytes when no output_path given.
38
+ - ``pages`` (int): Number of pages in the generated PDF.
39
+ - ``warnings`` (list[str]): Non-fatal warnings collected during render.
40
+ - ``metadata`` (dict): Echo of template and theme used.
41
+
42
+ Raises:
43
+ reportlab_json_renderer.utils.errors.ValidationError:
44
+ If the spec fails schema validation.
45
+ reportlab_json_renderer.utils.errors.RenderError:
46
+ If a block cannot be rendered.
47
+ """
48
+ return build_pdf(
49
+ spec,
50
+ output_path=output_path,
51
+ allow_partial=allow_partial,
52
+ asset_root=asset_root,
53
+ )
54
+
55
+
56
+ __all__ = ["__version__", "render_pdf"]
@@ -0,0 +1 @@
1
+ """Static assets: fonts, logos, and other embedded resources."""
@@ -0,0 +1,82 @@
1
+ """Block renderers.
2
+
3
+ Each module in this package implements one block type (title, table, chart, …)
4
+ and is registered with the block registry so the renderer can dispatch by type.
5
+
6
+ Usage::
7
+
8
+ from reportlab_json_renderer.blocks import get_renderer, render_block
9
+
10
+ renderer = get_renderer("title")
11
+ flowables = renderer.render(block_data, theme=theme, template=tpl, available_width=500)
12
+ """
13
+
14
+ from reportlab_json_renderer.blocks.base import BaseBlock
15
+ from reportlab_json_renderer.blocks.registry import (
16
+ get_renderer,
17
+ list_registered,
18
+ register,
19
+ render_block,
20
+ )
21
+
22
+ __all__ = [
23
+ "BaseBlock",
24
+ "get_renderer",
25
+ "list_registered",
26
+ "register",
27
+ "render_block",
28
+ ]
29
+
30
+ # ── Auto-register all built-in block renderers ──────────────────────
31
+
32
+
33
+ def _register_builtins() -> None:
34
+ """Register all built-in block renderers on first import."""
35
+ from reportlab_json_renderer.blocks.badge import BadgeBlock
36
+ from reportlab_json_renderer.blocks.callout import CalloutBlock
37
+ from reportlab_json_renderer.blocks.callout_group import CalloutGroupBlock
38
+ from reportlab_json_renderer.blocks.chart import ChartBlock
39
+ from reportlab_json_renderer.blocks.divider import DividerBlock
40
+ from reportlab_json_renderer.blocks.image import ImageBlock
41
+ from reportlab_json_renderer.blocks.insight_list import InsightListBlock
42
+ from reportlab_json_renderer.blocks.kpi_grid import KPIGridBlock
43
+ from reportlab_json_renderer.blocks.layout import TwoColumnBlock
44
+ from reportlab_json_renderer.blocks.matrix_table import MatrixTableBlock
45
+ from reportlab_json_renderer.blocks.page_break import PageBreakBlock
46
+ from reportlab_json_renderer.blocks.paragraph import ParagraphBlock
47
+ from reportlab_json_renderer.blocks.recommendations import RecommendationsBlock
48
+ from reportlab_json_renderer.blocks.registry import _REGISTRY
49
+ from reportlab_json_renderer.blocks.rich_text import RichTextBlock
50
+ from reportlab_json_renderer.blocks.section import SectionHeaderBlock
51
+ from reportlab_json_renderer.blocks.spacer import SpacerBlock
52
+ from reportlab_json_renderer.blocks.summary_box import SummaryBoxBlock
53
+ from reportlab_json_renderer.blocks.table import TableBlock
54
+ from reportlab_json_renderer.blocks.title import TitleBlock
55
+
56
+ for renderer_cls in (
57
+ TitleBlock,
58
+ SectionHeaderBlock,
59
+ ParagraphBlock,
60
+ RichTextBlock,
61
+ KPIGridBlock,
62
+ CalloutBlock,
63
+ CalloutGroupBlock,
64
+ TableBlock,
65
+ MatrixTableBlock,
66
+ InsightListBlock,
67
+ RecommendationsBlock,
68
+ ImageBlock,
69
+ ChartBlock,
70
+ TwoColumnBlock,
71
+ SpacerBlock,
72
+ PageBreakBlock,
73
+ DividerBlock,
74
+ BadgeBlock,
75
+ SummaryBoxBlock,
76
+ ):
77
+ r = renderer_cls()
78
+ if r.block_type not in _REGISTRY:
79
+ _REGISTRY[r.block_type] = r
80
+
81
+
82
+ _register_builtins()
@@ -0,0 +1,45 @@
1
+ """Badge block renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from reportlab.lib import colors
8
+ from reportlab.lib.styles import ParagraphStyle
9
+ from reportlab.platypus import Flowable, Paragraph
10
+
11
+ from reportlab_json_renderer.blocks.base import BaseBlock
12
+ from reportlab_json_renderer.utils.text import safe_paragraph_text
13
+
14
+
15
+ class BadgeBlock(BaseBlock):
16
+ """Render a small inline badge label."""
17
+
18
+ block_type = "badge"
19
+
20
+ def render(
21
+ self,
22
+ block: dict[str, Any],
23
+ *,
24
+ theme: Any,
25
+ template: Any,
26
+ available_width: float,
27
+ ) -> list[Flowable]:
28
+ label = safe_paragraph_text(str(block.get("label", "")))
29
+ tone = block.get("tone", "primary")
30
+
31
+ bg_color = theme.resolve_tone(tone) if theme else "#7CB518"
32
+ text_color = "#FFFFFF"
33
+
34
+ html = f'<font size="8" color="{text_color}">' f"<b>{label}</b>" f"</font>"
35
+
36
+ style = ParagraphStyle(
37
+ "Badge",
38
+ fontName=theme.font_bold if theme else "Helvetica-Bold",
39
+ fontSize=8,
40
+ textColor=colors.HexColor(text_color),
41
+ backColor=colors.HexColor(bg_color),
42
+ spaceAfter=4,
43
+ )
44
+
45
+ return [Paragraph(html, style)]
@@ -0,0 +1,61 @@
1
+ """Abstract base class for block renderers.
2
+
3
+ Every block type (title, paragraph, table, …) must subclass
4
+ :class:`BaseBlock` and implement :meth:`render`.
5
+
6
+ Block renderers receive the parsed block data, the active theme, and
7
+ the active template. They return a list of ReportLab ``Flowable``
8
+ objects that the PDF builder assembles into the document.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from typing import Any
15
+
16
+ from reportlab.platypus import Flowable
17
+
18
+
19
+ class BaseBlock(ABC):
20
+ """Abstract base for all block renderers.
21
+
22
+ Subclasses must set ``block_type`` as a class attribute and
23
+ implement :meth:`render`.
24
+
25
+ Class Attributes:
26
+ block_type: The JSON ``"type"`` value this renderer handles.
27
+ """
28
+
29
+ block_type: str
30
+
31
+ @abstractmethod
32
+ def render(
33
+ self,
34
+ block: dict[str, Any],
35
+ *,
36
+ theme: Any,
37
+ template: Any,
38
+ available_width: float,
39
+ ) -> list[Flowable]:
40
+ """Render a single block to ReportLab flowables.
41
+
42
+ Args:
43
+ block: The parsed block dictionary from the JSON spec.
44
+ theme: The active :class:`Theme` instance.
45
+ template: The active :class:`Template` instance.
46
+ available_width: Usable width in points (page width minus margins).
47
+
48
+ Returns:
49
+ List of ReportLab :class:`Flowable` objects.
50
+ """
51
+
52
+ def validate(self, block: dict[str, Any]) -> list[str]:
53
+ """Optional pre-render validation.
54
+
55
+ Args:
56
+ block: The raw block dictionary.
57
+
58
+ Returns:
59
+ List of warning strings. Empty if valid.
60
+ """
61
+ return []
@@ -0,0 +1,75 @@
1
+ """Callout block renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from reportlab.lib import colors
8
+ from reportlab.lib.styles import ParagraphStyle
9
+ from reportlab.platypus import Flowable, Paragraph, Spacer, Table, TableStyle
10
+
11
+ from reportlab_json_renderer.blocks.base import BaseBlock
12
+ from reportlab_json_renderer.utils.colors import tone_tint
13
+ from reportlab_json_renderer.utils.text import safe_paragraph_text
14
+
15
+
16
+ class CalloutBlock(BaseBlock):
17
+ """Render a coloured callout box with optional title and text."""
18
+
19
+ block_type = "callout"
20
+
21
+ def render(
22
+ self,
23
+ block: dict[str, Any],
24
+ *,
25
+ theme: Any,
26
+ template: Any,
27
+ available_width: float,
28
+ ) -> list[Flowable]:
29
+ tone = block.get("tone", "primary")
30
+ title = safe_paragraph_text(str(block.get("title", "")))
31
+ text = safe_paragraph_text(str(block.get("text", ""))).replace("\n", "<br/>")
32
+
33
+ border_color = theme.resolve_tone(tone) if theme else "#7CB518"
34
+ bg_color = tone_tint(tone, theme.tones if theme else None)
35
+ border_width = theme.callout_border_width if theme else 3.0
36
+
37
+ # Build inner content.
38
+ inner_parts: list[str] = []
39
+ if title:
40
+ inner_parts.append(f'<b><font size="10">{title}</font></b><br/>')
41
+ inner_parts.append(f'<font size="9">{text}</font>')
42
+ html = "".join(inner_parts)
43
+
44
+ text_style = ParagraphStyle(
45
+ "CalloutText",
46
+ fontName=theme.font_body if theme else "Helvetica",
47
+ fontSize=9,
48
+ leading=13,
49
+ textColor=colors.HexColor(theme.resolve_tone("dark") if theme else "#2D2D2D"),
50
+ )
51
+
52
+ para = Paragraph(html, text_style)
53
+
54
+ # Wrap in a table for the left-border effect.
55
+ table = Table([[para]], colWidths=[available_width - border_width - 12])
56
+ table.setStyle(
57
+ TableStyle(
58
+ [
59
+ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor(bg_color)),
60
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
61
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
62
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
63
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
64
+ (
65
+ "LINEBEFORETABLE",
66
+ (0, 0),
67
+ (0, -1),
68
+ border_width,
69
+ colors.HexColor(border_color),
70
+ ),
71
+ ]
72
+ )
73
+ )
74
+
75
+ return [Spacer(1, 4), table, Spacer(1, 6)]
@@ -0,0 +1,56 @@
1
+ """Callout group block renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from reportlab.platypus import Flowable, Paragraph
8
+
9
+ from reportlab_json_renderer.blocks.base import BaseBlock
10
+ from reportlab_json_renderer.blocks.callout import CalloutBlock
11
+ from reportlab_json_renderer.utils.text import safe_paragraph_text
12
+
13
+
14
+ class CalloutGroupBlock(BaseBlock):
15
+ """Render a group of callouts under a shared title."""
16
+
17
+ block_type = "callout_group"
18
+
19
+ def __init__(self) -> None:
20
+ self._callout_renderer = CalloutBlock()
21
+
22
+ def render(
23
+ self,
24
+ block: dict[str, Any],
25
+ *,
26
+ theme: Any,
27
+ template: Any,
28
+ available_width: float,
29
+ ) -> list[Flowable]:
30
+ title = safe_paragraph_text(str(block.get("title", "")))
31
+ items = block.get("items", [])
32
+ flowables: list[Flowable] = []
33
+
34
+ if title:
35
+ from reportlab.lib import colors
36
+ from reportlab.lib.styles import ParagraphStyle
37
+
38
+ title_style = ParagraphStyle(
39
+ "CalloutGroupTitle",
40
+ fontName=theme.font_bold if theme else "Helvetica-Bold",
41
+ fontSize=12,
42
+ textColor=colors.HexColor(theme.resolve_tone("dark") if theme else "#2D2D2D"),
43
+ spaceAfter=6,
44
+ )
45
+ flowables.append(Paragraph(title, title_style))
46
+
47
+ for item in items:
48
+ callout_flowables = self._callout_renderer.render(
49
+ item,
50
+ theme=theme,
51
+ template=template,
52
+ available_width=available_width,
53
+ )
54
+ flowables.extend(callout_flowables)
55
+
56
+ return flowables
@@ -0,0 +1,68 @@
1
+ """Chart block renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from reportlab.lib import colors
8
+ from reportlab.lib.styles import ParagraphStyle
9
+ from reportlab.platypus import Flowable, Paragraph, Spacer
10
+ from reportlab.platypus import Image as RLImage
11
+
12
+ from reportlab_json_renderer.blocks.base import BaseBlock
13
+ from reportlab_json_renderer.utils.charts import render_chart
14
+ from reportlab_json_renderer.utils.errors import RenderError
15
+ from reportlab_json_renderer.utils.text import safe_paragraph_text
16
+
17
+
18
+ class ChartBlock(BaseBlock):
19
+ """Render a chart as an embedded PNG image."""
20
+
21
+ block_type = "chart"
22
+
23
+ def render(
24
+ self,
25
+ block: dict[str, Any],
26
+ *,
27
+ theme: Any,
28
+ template: Any,
29
+ available_width: float,
30
+ ) -> list[Flowable]:
31
+ chart_type = block.get("chart_type", "bar")
32
+ title = safe_paragraph_text(str(block.get("title", "")))
33
+ labels = block.get("labels", [])
34
+ values = block.get("values", [])
35
+ series = block.get("series")
36
+ tone = block.get("tone")
37
+ flowables: list[Flowable] = []
38
+
39
+ if title:
40
+ title_style = ParagraphStyle(
41
+ "ChartTitle",
42
+ fontName=theme.font_bold if theme else "Helvetica-Bold",
43
+ fontSize=11,
44
+ textColor=colors.HexColor(theme.resolve_tone("dark") if theme else "#2D2D2D"),
45
+ spaceAfter=6,
46
+ )
47
+ flowables.append(Paragraph(title, title_style))
48
+
49
+ try:
50
+ theme_palette = theme.tones if theme else None
51
+ buf = render_chart(
52
+ chart_type=chart_type,
53
+ labels=labels,
54
+ values=values,
55
+ series=series,
56
+ tone=tone,
57
+ theme_palette=theme_palette,
58
+ )
59
+ img_width = available_width * 0.9
60
+ img_height = img_width * 0.5
61
+ img = RLImage(buf, width=img_width, height=img_height)
62
+ img.hAlign = "CENTER"
63
+ flowables.append(img)
64
+ except Exception as exc:
65
+ raise RenderError(f"Chart render failed: {exc}") from exc
66
+
67
+ flowables.append(Spacer(1, 8))
68
+ return flowables
@@ -0,0 +1,45 @@
1
+ """Divider block renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from reportlab.lib import colors
8
+ from reportlab.platypus import Flowable, Spacer
9
+
10
+ from reportlab_json_renderer.blocks.base import BaseBlock
11
+
12
+
13
+ class DividerBlock(BaseBlock):
14
+ """Render a horizontal divider line."""
15
+
16
+ block_type = "divider"
17
+
18
+ def render(
19
+ self,
20
+ block: dict[str, Any],
21
+ *,
22
+ theme: Any,
23
+ template: Any,
24
+ available_width: float,
25
+ ) -> list[Flowable]:
26
+ tone = block.get("tone", "primary")
27
+ thickness = block.get("thickness", 1.0)
28
+ line = _DividerLine(available_width, tone, thickness, theme)
29
+ return [Spacer(1, 4), line, Spacer(1, 4)]
30
+
31
+
32
+ class _DividerLine(Flowable):
33
+ def __init__(self, width: float, tone: str, thickness: float, theme: Any = None) -> None:
34
+ super().__init__()
35
+ self.width = width
36
+ self.height = thickness
37
+ self._tone = tone
38
+ self._thickness = thickness
39
+ self._theme = theme
40
+
41
+ def draw(self) -> None:
42
+ hex_color = self._theme.resolve_tone(self._tone) if self._theme else "#7CB518"
43
+ self.canv.setStrokeColor(colors.HexColor(hex_color))
44
+ self.canv.setLineWidth(self._thickness)
45
+ self.canv.line(0, 0, self.width, 0)
@@ -0,0 +1,58 @@
1
+ """Image block renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from reportlab.lib import colors
8
+ from reportlab.lib.styles import ParagraphStyle
9
+ from reportlab.platypus import Flowable, Paragraph, Spacer
10
+ from reportlab.platypus import Image as RLImage
11
+
12
+ from reportlab_json_renderer.blocks.base import BaseBlock
13
+ from reportlab_json_renderer.utils.images import load_local_image
14
+ from reportlab_json_renderer.utils.text import safe_paragraph_text
15
+ from reportlab_json_renderer.utils.units import cm_to_pt
16
+
17
+
18
+ class ImageBlock(BaseBlock):
19
+ """Render an image block from a local file path."""
20
+
21
+ block_type = "image"
22
+
23
+ def render(
24
+ self,
25
+ block: dict[str, Any],
26
+ *,
27
+ theme: Any,
28
+ template: Any,
29
+ available_width: float,
30
+ ) -> list[Flowable]:
31
+ title = safe_paragraph_text(str(block.get("title", "")))
32
+ src = block.get("src", "")
33
+ width_cm = block.get("width_cm")
34
+ height_cm = block.get("height_cm")
35
+ align = block.get("align", "center")
36
+ flowables: list[Flowable] = []
37
+
38
+ if title:
39
+ title_style = ParagraphStyle(
40
+ "ImageTitle",
41
+ fontName=theme.font_bold if theme else "Helvetica-Bold",
42
+ fontSize=10,
43
+ textColor=colors.HexColor(theme.resolve_tone("dark") if theme else "#2D2D2D"),
44
+ spaceAfter=4,
45
+ )
46
+ flowables.append(Paragraph(title, title_style))
47
+
48
+ # Resolve dimensions.
49
+ w = cm_to_pt(width_cm) if width_cm else available_width * 0.8
50
+ h = cm_to_pt(height_cm) if height_cm else w * 0.5
51
+
52
+ image_path = load_local_image(src)
53
+ img = RLImage(str(image_path), width=w, height=h)
54
+ img.hAlign = align.upper()
55
+ flowables.append(img)
56
+
57
+ flowables.append(Spacer(1, 8))
58
+ return flowables
@@ -0,0 +1,62 @@
1
+ """Insight list block renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from reportlab.lib import colors
8
+ from reportlab.lib.styles import ParagraphStyle
9
+ from reportlab.platypus import Flowable, Paragraph
10
+
11
+ from reportlab_json_renderer.blocks.base import BaseBlock
12
+ from reportlab_json_renderer.utils.text import safe_paragraph_text
13
+
14
+
15
+ class InsightListBlock(BaseBlock):
16
+ """Render a numbered/bulleted insight list."""
17
+
18
+ block_type = "insight_list"
19
+
20
+ def render(
21
+ self,
22
+ block: dict[str, Any],
23
+ *,
24
+ theme: Any,
25
+ template: Any,
26
+ available_width: float,
27
+ ) -> list[Flowable]:
28
+ title = safe_paragraph_text(str(block.get("title", "")))
29
+ items = block.get("items", [])
30
+ flowables: list[Flowable] = []
31
+
32
+ if title:
33
+ title_style = ParagraphStyle(
34
+ "InsightTitle",
35
+ fontName=theme.font_bold if theme else "Helvetica-Bold",
36
+ fontSize=12,
37
+ textColor=colors.HexColor(theme.resolve_tone("dark") if theme else "#2D2D2D"),
38
+ spaceAfter=8,
39
+ )
40
+ flowables.append(Paragraph(title, title_style))
41
+
42
+ for idx, item in enumerate(items, 1):
43
+ item_title = safe_paragraph_text(str(item.get("title", "")))
44
+ item_text = safe_paragraph_text(str(item.get("text", ""))).replace("\n", "<br/>")
45
+
46
+ html_parts = [f"<b>{idx}. {item_title}</b>"]
47
+ if item_text:
48
+ html_parts.append(f"<br/>{item_text}")
49
+ html = "".join(html_parts)
50
+
51
+ style = ParagraphStyle(
52
+ "InsightItem",
53
+ fontName=theme.font_body if theme else "Helvetica",
54
+ fontSize=10,
55
+ leading=14,
56
+ textColor=colors.HexColor(theme.resolve_tone("dark") if theme else "#2D2D2D"),
57
+ spaceBefore=4,
58
+ spaceAfter=4,
59
+ )
60
+ flowables.append(Paragraph(html, style))
61
+
62
+ return flowables