pdfblox 1.0.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.
- pdfblox/__init__.py +127 -0
- pdfblox/charts.py +381 -0
- pdfblox/document.py +611 -0
- pdfblox/fonts.py +101 -0
- pdfblox/footer.py +73 -0
- pdfblox/header.py +207 -0
- pdfblox/images.py +77 -0
- pdfblox/layout.py +143 -0
- pdfblox/lists.py +111 -0
- pdfblox/report.py +222 -0
- pdfblox/tables.py +317 -0
- pdfblox/text.py +107 -0
- pdfblox/theme.py +222 -0
- pdfblox/themes.py +166 -0
- pdfblox/utils.py +105 -0
- pdfblox-1.0.0.dist-info/METADATA +220 -0
- pdfblox-1.0.0.dist-info/RECORD +20 -0
- pdfblox-1.0.0.dist-info/WHEEL +5 -0
- pdfblox-1.0.0.dist-info/licenses/LICENSE +21 -0
- pdfblox-1.0.0.dist-info/top_level.txt +1 -0
pdfblox/__init__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Copyright (c) 2026 Aleksey Suvorov
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
pdfblox — composable PDF building blocks.
|
|
5
|
+
|
|
6
|
+
A dependency-light library (ReportLab only) for assembling PDF reports from
|
|
7
|
+
declarative building blocks: styled tables, key/value tables, text and
|
|
8
|
+
headings, images, layout primitives (spacers, dividers, KPI callouts), page
|
|
9
|
+
headers/footers, title pages, and tile grids — composed via a fluent
|
|
10
|
+
``Report`` builder.
|
|
11
|
+
|
|
12
|
+
pdfblox knows nothing about where data or images come from. Callers pass in
|
|
13
|
+
plain Python data (``list[dict]`` rows), image *bytes* or ready-made
|
|
14
|
+
ReportLab flowables, and configuration objects. Fetching data, rendering
|
|
15
|
+
charts, and loading environment/credentials are the responsibility of the
|
|
16
|
+
integration layer that sits above this library.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .tables import (
|
|
20
|
+
ColumnConfig,
|
|
21
|
+
TableConfig,
|
|
22
|
+
create_table,
|
|
23
|
+
KeyValueConfig,
|
|
24
|
+
create_keyvalue_table,
|
|
25
|
+
)
|
|
26
|
+
from .text import TextConfig, paragraph, heading
|
|
27
|
+
from .lists import ListConfig, bullet_list, numbered_list
|
|
28
|
+
from .images import make_image
|
|
29
|
+
from .layout import CalloutConfig, spacer, divider, kpi_callout
|
|
30
|
+
from .charts import (
|
|
31
|
+
ChartConfig, bar_chart, line_chart, pie_chart, scatter_chart,
|
|
32
|
+
)
|
|
33
|
+
from .header import HeaderConfig, draw_page_header
|
|
34
|
+
from .footer import FooterConfig, draw_page_footer
|
|
35
|
+
from .document import (
|
|
36
|
+
PdfConfig,
|
|
37
|
+
TitlePageConfig,
|
|
38
|
+
TileGridConfig,
|
|
39
|
+
build_pdf,
|
|
40
|
+
build_paginated_pdf,
|
|
41
|
+
build_tile_grid,
|
|
42
|
+
)
|
|
43
|
+
from .report import Report
|
|
44
|
+
from .theme import Theme
|
|
45
|
+
from .fonts import register_font, use_unicode_fonts
|
|
46
|
+
from .themes import (
|
|
47
|
+
corporate_blue,
|
|
48
|
+
slate_gray,
|
|
49
|
+
forest_green,
|
|
50
|
+
burgundy,
|
|
51
|
+
teal,
|
|
52
|
+
graphite,
|
|
53
|
+
get_theme,
|
|
54
|
+
available_themes,
|
|
55
|
+
)
|
|
56
|
+
from .utils import (
|
|
57
|
+
parse_timestamp,
|
|
58
|
+
normalize_date,
|
|
59
|
+
blank_to_none,
|
|
60
|
+
open_file_in_windows,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
__version__ = "1.0.0"
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# Tables
|
|
67
|
+
"ColumnConfig",
|
|
68
|
+
"TableConfig",
|
|
69
|
+
"create_table",
|
|
70
|
+
"KeyValueConfig",
|
|
71
|
+
"create_keyvalue_table",
|
|
72
|
+
# Text
|
|
73
|
+
"TextConfig",
|
|
74
|
+
"paragraph",
|
|
75
|
+
"heading",
|
|
76
|
+
# Lists
|
|
77
|
+
"ListConfig",
|
|
78
|
+
"bullet_list",
|
|
79
|
+
"numbered_list",
|
|
80
|
+
# Images
|
|
81
|
+
"make_image",
|
|
82
|
+
# Layout primitives
|
|
83
|
+
"CalloutConfig",
|
|
84
|
+
"spacer",
|
|
85
|
+
"divider",
|
|
86
|
+
"kpi_callout",
|
|
87
|
+
# Charts (native vector)
|
|
88
|
+
"ChartConfig",
|
|
89
|
+
"bar_chart",
|
|
90
|
+
"line_chart",
|
|
91
|
+
"pie_chart",
|
|
92
|
+
"scatter_chart",
|
|
93
|
+
# Header
|
|
94
|
+
"HeaderConfig",
|
|
95
|
+
"draw_page_header",
|
|
96
|
+
# Footer
|
|
97
|
+
"FooterConfig",
|
|
98
|
+
"draw_page_footer",
|
|
99
|
+
# Document assembly
|
|
100
|
+
"PdfConfig",
|
|
101
|
+
"TitlePageConfig",
|
|
102
|
+
"TileGridConfig",
|
|
103
|
+
"build_pdf",
|
|
104
|
+
"build_paginated_pdf",
|
|
105
|
+
"build_tile_grid",
|
|
106
|
+
# Fluent builder
|
|
107
|
+
"Report",
|
|
108
|
+
# Theme
|
|
109
|
+
"Theme",
|
|
110
|
+
# Fonts (Unicode support)
|
|
111
|
+
"register_font",
|
|
112
|
+
"use_unicode_fonts",
|
|
113
|
+
# Theme presets
|
|
114
|
+
"corporate_blue",
|
|
115
|
+
"slate_gray",
|
|
116
|
+
"forest_green",
|
|
117
|
+
"burgundy",
|
|
118
|
+
"teal",
|
|
119
|
+
"graphite",
|
|
120
|
+
"get_theme",
|
|
121
|
+
"available_themes",
|
|
122
|
+
# Utilities
|
|
123
|
+
"parse_timestamp",
|
|
124
|
+
"normalize_date",
|
|
125
|
+
"blank_to_none",
|
|
126
|
+
"open_file_in_windows",
|
|
127
|
+
]
|
pdfblox/charts.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# Copyright (c) 2026 Aleksey Suvorov
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
pdfblox.charts
|
|
5
|
+
Native vector chart blocks built on ReportLab's own graphics engine
|
|
6
|
+
(``reportlab.graphics``) — no external charting or rendering dependency.
|
|
7
|
+
|
|
8
|
+
Each function takes in-memory numeric data and returns a ``Drawing`` flowable
|
|
9
|
+
that renders as crisp vectors directly in the PDF. This is the right tool for
|
|
10
|
+
the standard reporting charts (bar, line, pie/donut, scatter).
|
|
11
|
+
|
|
12
|
+
For charts that must reproduce a figure rendered elsewhere (Plotly, matplotlib,
|
|
13
|
+
a server-side SVG/PNG), use :func:`pdfblox.images.make_image` with the rendered
|
|
14
|
+
bytes instead — rendering stays outside this library.
|
|
15
|
+
|
|
16
|
+
Data shapes:
|
|
17
|
+
* bar / line: a single series ``[1, 2, 3]`` or multiple series
|
|
18
|
+
``[[1, 2, 3], [4, 5, 6]]``.
|
|
19
|
+
* pie / donut: a flat list of values ``[30, 50, 20]``.
|
|
20
|
+
* scatter: one series of points ``[(x, y), ...]`` or several
|
|
21
|
+
``[[(x, y), ...], [(x, y), ...]]``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
from reportlab.lib import colors
|
|
28
|
+
from reportlab.graphics.shapes import Drawing, String, Rect, Group
|
|
29
|
+
from reportlab.graphics.charts.barcharts import VerticalBarChart, HorizontalBarChart
|
|
30
|
+
from reportlab.graphics.charts.linecharts import HorizontalLineChart
|
|
31
|
+
from reportlab.graphics.charts.lineplots import LinePlot
|
|
32
|
+
from reportlab.graphics.charts.piecharts import Pie
|
|
33
|
+
from reportlab.graphics.charts.doughnut import Doughnut
|
|
34
|
+
from reportlab.graphics.charts.legends import Legend
|
|
35
|
+
from reportlab.graphics.widgets.markers import makeMarker
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Fallback qualitative palette used when no theme/config supplies colours.
|
|
39
|
+
_DEFAULT_COLORS = [
|
|
40
|
+
colors.HexColor(c) for c in (
|
|
41
|
+
"#1F4E79", "#E08E45", "#6A8D73", "#8E7CC3",
|
|
42
|
+
"#C25B56", "#4E79A7", "#9C755F", "#BAB0AC",
|
|
43
|
+
)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ChartConfig:
|
|
49
|
+
"""Styling shared by all chart blocks.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
width: Drawing width in points.
|
|
53
|
+
height: Drawing height in points.
|
|
54
|
+
colors: Series colour sequence (cycled if there are more series).
|
|
55
|
+
background: Optional drawing background fill. *None* = transparent.
|
|
56
|
+
font: Font for axis/value labels.
|
|
57
|
+
font_size: Label font size.
|
|
58
|
+
label_color: Axis label text colour.
|
|
59
|
+
axis_color: Axis line colour.
|
|
60
|
+
grid_color: Value-axis gridline colour. *None* hides the grid.
|
|
61
|
+
show_legend: Draw a legend (needs series/slice names).
|
|
62
|
+
legend_font_size: Legend font size.
|
|
63
|
+
title: Optional chart title drawn centred at the top.
|
|
64
|
+
title_font: Title font.
|
|
65
|
+
title_font_size: Title font size.
|
|
66
|
+
title_color: Title colour.
|
|
67
|
+
x_title: Optional label drawn under the x axis.
|
|
68
|
+
y_title: Optional label drawn (rotated) beside the y axis.
|
|
69
|
+
marker_size: Marker diameter for line/scatter points.
|
|
70
|
+
"""
|
|
71
|
+
width: float = 400
|
|
72
|
+
height: float = 220
|
|
73
|
+
colors: list = field(default_factory=lambda: list(_DEFAULT_COLORS))
|
|
74
|
+
background: Optional[Any] = None
|
|
75
|
+
font: str = "Helvetica"
|
|
76
|
+
font_size: int = 8
|
|
77
|
+
label_color: Any = field(default_factory=lambda: colors.black)
|
|
78
|
+
axis_color: Any = field(default_factory=lambda: colors.HexColor("#666666"))
|
|
79
|
+
grid_color: Optional[Any] = field(default_factory=lambda: colors.HexColor("#DDDDDD"))
|
|
80
|
+
show_legend: bool = False
|
|
81
|
+
legend_font_size: int = 7
|
|
82
|
+
title: str = ""
|
|
83
|
+
title_font: str = "Helvetica-Bold"
|
|
84
|
+
title_font_size: int = 10
|
|
85
|
+
title_color: Any = field(default_factory=lambda: colors.black)
|
|
86
|
+
x_title: str = ""
|
|
87
|
+
y_title: str = ""
|
|
88
|
+
marker_size: float = 4
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# =========================================================================
|
|
92
|
+
# Internal helpers
|
|
93
|
+
# =========================================================================
|
|
94
|
+
|
|
95
|
+
def _is_number(x) -> bool:
|
|
96
|
+
return isinstance(x, (int, float)) and not isinstance(x, bool)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_series(data) -> list:
|
|
100
|
+
"""Coerce a single series or a list of series into a list of series."""
|
|
101
|
+
if data and _is_number(data[0]):
|
|
102
|
+
return [list(data)]
|
|
103
|
+
return [list(s) for s in data]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _margins(cfg: ChartConfig) -> tuple:
|
|
107
|
+
"""(left, right, top, bottom) padding reserved for labels/title/legend."""
|
|
108
|
+
left = 45 + (cfg.font_size + 6 if cfg.y_title else 0)
|
|
109
|
+
right = 15
|
|
110
|
+
top = 8 + (cfg.title_font_size + 6 if cfg.title else 0)
|
|
111
|
+
bottom = 22 # category tick labels
|
|
112
|
+
bottom += _legend_band(cfg) # legend sits in its own band at the very bottom
|
|
113
|
+
bottom += cfg.font_size + 6 if cfg.x_title else 0
|
|
114
|
+
return left, right, top, bottom
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _legend_band(cfg: ChartConfig) -> float:
|
|
118
|
+
"""Vertical space reserved for the legend at the bottom of the drawing."""
|
|
119
|
+
return (cfg.legend_font_size + 14) if cfg.show_legend else 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _add_axis_titles(d: Drawing, cfg: ChartConfig, plot) -> None:
|
|
123
|
+
"""Draw optional x/y axis titles around a placed chart/plot.
|
|
124
|
+
|
|
125
|
+
The x-axis title sits in a band just above the legend band, so the two
|
|
126
|
+
never overlap when both are enabled.
|
|
127
|
+
"""
|
|
128
|
+
if cfg.x_title:
|
|
129
|
+
s = String(plot.x + plot.width / 2, _legend_band(cfg) + 4, cfg.x_title)
|
|
130
|
+
s.fontName = cfg.font
|
|
131
|
+
s.fontSize = cfg.font_size
|
|
132
|
+
s.fillColor = cfg.label_color
|
|
133
|
+
s.textAnchor = "middle"
|
|
134
|
+
d.add(s)
|
|
135
|
+
if cfg.y_title:
|
|
136
|
+
# String has no angle attr; rotate via a Group transform instead.
|
|
137
|
+
s = String(0, 0, cfg.y_title)
|
|
138
|
+
s.fontName = cfg.font
|
|
139
|
+
s.fontSize = cfg.font_size
|
|
140
|
+
s.fillColor = cfg.label_color
|
|
141
|
+
s.textAnchor = "middle"
|
|
142
|
+
g = Group(s)
|
|
143
|
+
g.translate(12, plot.y + plot.height / 2)
|
|
144
|
+
g.rotate(90)
|
|
145
|
+
d.add(g)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _new_drawing(cfg: ChartConfig) -> Drawing:
|
|
149
|
+
d = Drawing(cfg.width, cfg.height)
|
|
150
|
+
if cfg.background is not None:
|
|
151
|
+
d.add(Rect(0, 0, cfg.width, cfg.height,
|
|
152
|
+
fillColor=cfg.background, strokeColor=None))
|
|
153
|
+
return d
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _add_title(d: Drawing, cfg: ChartConfig) -> None:
|
|
157
|
+
if cfg.title:
|
|
158
|
+
s = String(cfg.width / 2, cfg.height - cfg.title_font_size - 2, cfg.title)
|
|
159
|
+
s.fontName = cfg.title_font
|
|
160
|
+
s.fontSize = cfg.title_font_size
|
|
161
|
+
s.fillColor = cfg.title_color
|
|
162
|
+
s.textAnchor = "middle"
|
|
163
|
+
d.add(s)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _add_legend(d: Drawing, cfg: ChartConfig, names) -> None:
|
|
167
|
+
if not (cfg.show_legend and names):
|
|
168
|
+
return
|
|
169
|
+
leg = Legend()
|
|
170
|
+
leg.colorNamePairs = [
|
|
171
|
+
(cfg.colors[i % len(cfg.colors)], str(n)) for i, n in enumerate(names)
|
|
172
|
+
]
|
|
173
|
+
leg.fontName = cfg.font
|
|
174
|
+
leg.fontSize = cfg.legend_font_size
|
|
175
|
+
leg.x = cfg.width / 2
|
|
176
|
+
leg.boxAnchor = "s"
|
|
177
|
+
leg.dx = 6
|
|
178
|
+
leg.dy = 6
|
|
179
|
+
leg.dxTextSpace = 4
|
|
180
|
+
leg.columnMaximum = 1 # one item per column -> single horizontal row
|
|
181
|
+
leg.alignment = "right"
|
|
182
|
+
leg.deltax = 70
|
|
183
|
+
# Anchor the legend box to the very bottom: measure at y=0, then shift so
|
|
184
|
+
# its bounding-box bottom lands at y=4. (Legend.y maps 1:1 to bounds.)
|
|
185
|
+
leg.y = 0
|
|
186
|
+
bottom = leg.getBounds()[1]
|
|
187
|
+
leg.y = 4 - bottom
|
|
188
|
+
d.add(leg)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _style_cartesian(chart, cfg: ChartConfig, categories) -> None:
|
|
192
|
+
"""Apply fonts, colours, and grid to a category/value chart."""
|
|
193
|
+
if categories:
|
|
194
|
+
chart.categoryAxis.categoryNames = [str(c) for c in categories]
|
|
195
|
+
for ax in (chart.categoryAxis, chart.valueAxis):
|
|
196
|
+
ax.labels.fontName = cfg.font
|
|
197
|
+
ax.labels.fontSize = cfg.font_size
|
|
198
|
+
ax.labels.fillColor = cfg.label_color
|
|
199
|
+
ax.strokeColor = cfg.axis_color
|
|
200
|
+
if cfg.grid_color is not None:
|
|
201
|
+
chart.valueAxis.visibleGrid = 1
|
|
202
|
+
chart.valueAxis.gridStrokeColor = cfg.grid_color
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# =========================================================================
|
|
206
|
+
# Chart blocks
|
|
207
|
+
# =========================================================================
|
|
208
|
+
|
|
209
|
+
def bar_chart(data, categories=None, series_names=None, *,
|
|
210
|
+
config: Optional[ChartConfig] = None, horizontal: bool = False) -> Drawing:
|
|
211
|
+
"""Vertical (default) or horizontal bar chart.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
data: One series or a list of series of numbers.
|
|
215
|
+
categories: Category (x) axis labels.
|
|
216
|
+
series_names: Names for the legend (one per series).
|
|
217
|
+
config: Optional :class:`ChartConfig`.
|
|
218
|
+
horizontal: Draw horizontal bars when true.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
A ReportLab ``Drawing`` flowable.
|
|
222
|
+
"""
|
|
223
|
+
cfg = config or ChartConfig()
|
|
224
|
+
series = _normalize_series(data)
|
|
225
|
+
d = _new_drawing(cfg)
|
|
226
|
+
|
|
227
|
+
chart = HorizontalBarChart() if horizontal else VerticalBarChart()
|
|
228
|
+
left, right, top, bottom = _margins(cfg)
|
|
229
|
+
chart.x, chart.y = left, bottom
|
|
230
|
+
chart.width = cfg.width - left - right
|
|
231
|
+
chart.height = cfg.height - top - bottom
|
|
232
|
+
chart.data = series
|
|
233
|
+
# Anchor the axis at zero only when all values are non-negative; otherwise
|
|
234
|
+
# let it auto-scale so negative bars (variance/deltas) render correctly.
|
|
235
|
+
flat = [v for s in series for v in s]
|
|
236
|
+
if flat and min(flat) >= 0:
|
|
237
|
+
chart.valueAxis.valueMin = 0
|
|
238
|
+
_style_cartesian(chart, cfg, categories)
|
|
239
|
+
|
|
240
|
+
for i in range(len(series)):
|
|
241
|
+
chart.bars[i].fillColor = cfg.colors[i % len(cfg.colors)]
|
|
242
|
+
chart.bars[i].strokeColor = None
|
|
243
|
+
|
|
244
|
+
d.add(chart)
|
|
245
|
+
_add_title(d, cfg)
|
|
246
|
+
_add_axis_titles(d, cfg, chart)
|
|
247
|
+
_add_legend(d, cfg, series_names)
|
|
248
|
+
return d
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def line_chart(data, categories=None, series_names=None, *,
|
|
252
|
+
config: Optional[ChartConfig] = None, markers: bool = True) -> Drawing:
|
|
253
|
+
"""Category-based line chart (trends over ordered categories).
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
data: One series or a list of series of numbers.
|
|
257
|
+
categories: Category (x) axis labels.
|
|
258
|
+
series_names: Names for the legend (one per series).
|
|
259
|
+
config: Optional :class:`ChartConfig`.
|
|
260
|
+
markers: Draw a point marker at each value.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
A ReportLab ``Drawing`` flowable.
|
|
264
|
+
"""
|
|
265
|
+
cfg = config or ChartConfig()
|
|
266
|
+
series = _normalize_series(data)
|
|
267
|
+
d = _new_drawing(cfg)
|
|
268
|
+
|
|
269
|
+
chart = HorizontalLineChart()
|
|
270
|
+
left, right, top, bottom = _margins(cfg)
|
|
271
|
+
chart.x, chart.y = left, bottom
|
|
272
|
+
chart.width = cfg.width - left - right
|
|
273
|
+
chart.height = cfg.height - top - bottom
|
|
274
|
+
chart.data = series
|
|
275
|
+
_style_cartesian(chart, cfg, categories)
|
|
276
|
+
|
|
277
|
+
for i in range(len(series)):
|
|
278
|
+
color = cfg.colors[i % len(cfg.colors)]
|
|
279
|
+
chart.lines[i].strokeColor = color
|
|
280
|
+
chart.lines[i].strokeWidth = 1.5
|
|
281
|
+
if markers:
|
|
282
|
+
chart.lines[i].symbol = makeMarker("FilledCircle")
|
|
283
|
+
chart.lines[i].symbol.size = cfg.marker_size
|
|
284
|
+
chart.lines[i].symbol.fillColor = color
|
|
285
|
+
|
|
286
|
+
d.add(chart)
|
|
287
|
+
_add_title(d, cfg)
|
|
288
|
+
_add_axis_titles(d, cfg, chart)
|
|
289
|
+
_add_legend(d, cfg, series_names)
|
|
290
|
+
return d
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def pie_chart(values, labels=None, *,
|
|
294
|
+
config: Optional[ChartConfig] = None, donut: bool = False) -> Drawing:
|
|
295
|
+
"""Pie or donut chart of part-to-whole values.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
values: Flat list of slice values.
|
|
299
|
+
labels: Slice labels (shown on slices, or in the legend when
|
|
300
|
+
``show_legend`` is set).
|
|
301
|
+
config: Optional :class:`ChartConfig`.
|
|
302
|
+
donut: Render a donut (ring) instead of a full pie.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
A ReportLab ``Drawing`` flowable.
|
|
306
|
+
"""
|
|
307
|
+
cfg = config or ChartConfig()
|
|
308
|
+
d = _new_drawing(cfg)
|
|
309
|
+
|
|
310
|
+
pie = Doughnut() if donut else Pie()
|
|
311
|
+
left, right, top, bottom = _margins(cfg)
|
|
312
|
+
avail_h = cfg.height - top - bottom
|
|
313
|
+
size = max(10, min(cfg.width - left - right, avail_h))
|
|
314
|
+
pie.width = pie.height = size
|
|
315
|
+
pie.x = (cfg.width - size) / 2
|
|
316
|
+
pie.y = bottom
|
|
317
|
+
pie.data = list(values)
|
|
318
|
+
if labels and not cfg.show_legend:
|
|
319
|
+
pie.labels = [str(l) for l in labels]
|
|
320
|
+
pie.slices.strokeColor = colors.white
|
|
321
|
+
pie.slices.strokeWidth = 0.5
|
|
322
|
+
for i in range(len(values)):
|
|
323
|
+
pie.slices[i].fillColor = cfg.colors[i % len(cfg.colors)]
|
|
324
|
+
|
|
325
|
+
d.add(pie)
|
|
326
|
+
_add_title(d, cfg)
|
|
327
|
+
_add_legend(d, cfg, labels)
|
|
328
|
+
return d
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def scatter_chart(series, series_names=None, *,
|
|
332
|
+
config: Optional[ChartConfig] = None) -> Drawing:
|
|
333
|
+
"""XY scatter plot.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
series: One series of ``(x, y)`` points, or a list of such series.
|
|
337
|
+
series_names: Names for the legend (one per series).
|
|
338
|
+
config: Optional :class:`ChartConfig`.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
A ReportLab ``Drawing`` flowable.
|
|
342
|
+
"""
|
|
343
|
+
cfg = config or ChartConfig()
|
|
344
|
+
|
|
345
|
+
# One series of points -> wrap; otherwise treat as list of series.
|
|
346
|
+
if (series and isinstance(series[0], (tuple, list))
|
|
347
|
+
and len(series[0]) == 2 and _is_number(series[0][0])):
|
|
348
|
+
data = [[tuple(p) for p in series]]
|
|
349
|
+
else:
|
|
350
|
+
data = [[tuple(p) for p in s] for s in series]
|
|
351
|
+
|
|
352
|
+
d = _new_drawing(cfg)
|
|
353
|
+
plot = LinePlot()
|
|
354
|
+
left, right, top, bottom = _margins(cfg)
|
|
355
|
+
plot.x, plot.y = left, bottom
|
|
356
|
+
plot.width = cfg.width - left - right
|
|
357
|
+
plot.height = cfg.height - top - bottom
|
|
358
|
+
plot.data = data
|
|
359
|
+
plot.joinedLines = 0 # points only
|
|
360
|
+
|
|
361
|
+
for i in range(len(data)):
|
|
362
|
+
color = cfg.colors[i % len(cfg.colors)]
|
|
363
|
+
plot.lines[i].strokeColor = color
|
|
364
|
+
plot.lines[i].symbol = makeMarker("FilledCircle")
|
|
365
|
+
plot.lines[i].symbol.size = cfg.marker_size
|
|
366
|
+
plot.lines[i].symbol.fillColor = color
|
|
367
|
+
|
|
368
|
+
for ax in (plot.xValueAxis, plot.yValueAxis):
|
|
369
|
+
ax.labels.fontName = cfg.font
|
|
370
|
+
ax.labels.fontSize = cfg.font_size
|
|
371
|
+
ax.labels.fillColor = cfg.label_color
|
|
372
|
+
ax.strokeColor = cfg.axis_color
|
|
373
|
+
if cfg.grid_color is not None:
|
|
374
|
+
plot.yValueAxis.visibleGrid = 1
|
|
375
|
+
plot.yValueAxis.gridStrokeColor = cfg.grid_color
|
|
376
|
+
|
|
377
|
+
d.add(plot)
|
|
378
|
+
_add_title(d, cfg)
|
|
379
|
+
_add_axis_titles(d, cfg, plot)
|
|
380
|
+
_add_legend(d, cfg, series_names)
|
|
381
|
+
return d
|