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 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