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.
- reportlab_json_renderer/__init__.py +56 -0
- reportlab_json_renderer/assets/__init__.py +1 -0
- reportlab_json_renderer/blocks/__init__.py +82 -0
- reportlab_json_renderer/blocks/badge.py +45 -0
- reportlab_json_renderer/blocks/base.py +61 -0
- reportlab_json_renderer/blocks/callout.py +75 -0
- reportlab_json_renderer/blocks/callout_group.py +56 -0
- reportlab_json_renderer/blocks/chart.py +68 -0
- reportlab_json_renderer/blocks/divider.py +45 -0
- reportlab_json_renderer/blocks/image.py +58 -0
- reportlab_json_renderer/blocks/insight_list.py +62 -0
- reportlab_json_renderer/blocks/kpi_grid.py +120 -0
- reportlab_json_renderer/blocks/layout.py +79 -0
- reportlab_json_renderer/blocks/matrix_table.py +107 -0
- reportlab_json_renderer/blocks/page_break.py +26 -0
- reportlab_json_renderer/blocks/paragraph.py +53 -0
- reportlab_json_renderer/blocks/recommendations.py +99 -0
- reportlab_json_renderer/blocks/registry.py +92 -0
- reportlab_json_renderer/blocks/rich_text.py +101 -0
- reportlab_json_renderer/blocks/section.py +64 -0
- reportlab_json_renderer/blocks/spacer.py +27 -0
- reportlab_json_renderer/blocks/summary_box.py +66 -0
- reportlab_json_renderer/blocks/table.py +133 -0
- reportlab_json_renderer/blocks/title.py +107 -0
- reportlab_json_renderer/cli.py +388 -0
- reportlab_json_renderer/renderer.py +348 -0
- reportlab_json_renderer/schema/__init__.py +19 -0
- reportlab_json_renderer/schema/base.py +387 -0
- reportlab_json_renderer/schema/constants.py +23 -0
- reportlab_json_renderer/schema/validators.py +286 -0
- reportlab_json_renderer/templates/__init__.py +31 -0
- reportlab_json_renderer/templates/analytics_report_v1.py +19 -0
- reportlab_json_renderer/templates/base.py +139 -0
- reportlab_json_renderer/templates/business_report_v1.py +19 -0
- reportlab_json_renderer/templates/compact_report_v1.py +19 -0
- reportlab_json_renderer/templates/invoice_v1.py +19 -0
- reportlab_json_renderer/templates/proposal_v1.py +19 -0
- reportlab_json_renderer/templates/registry.py +79 -0
- reportlab_json_renderer/themes/__init__.py +25 -0
- reportlab_json_renderer/themes/base.py +145 -0
- reportlab_json_renderer/themes/dark.py +30 -0
- reportlab_json_renderer/themes/green.py +27 -0
- reportlab_json_renderer/themes/neutral.py +28 -0
- reportlab_json_renderer/themes/registry.py +77 -0
- reportlab_json_renderer/utils/__init__.py +1 -0
- reportlab_json_renderer/utils/charts.py +489 -0
- reportlab_json_renderer/utils/colors.py +120 -0
- reportlab_json_renderer/utils/errors.py +36 -0
- reportlab_json_renderer/utils/fonts.py +77 -0
- reportlab_json_renderer/utils/images.py +199 -0
- reportlab_json_renderer/utils/text.py +131 -0
- reportlab_json_renderer/utils/units.py +82 -0
- reportlab_json_renderer-0.1.0.dist-info/METADATA +347 -0
- reportlab_json_renderer-0.1.0.dist-info/RECORD +57 -0
- reportlab_json_renderer-0.1.0.dist-info/WHEEL +4 -0
- reportlab_json_renderer-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|