@nomad-e/bluma-cli 0.1.22 → 0.1.23
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.
|
@@ -5,288 +5,448 @@ description: >
|
|
|
5
5
|
new PDFs from scratch, reading or extracting text and tables from PDFs,
|
|
6
6
|
merging or splitting PDFs, rotating pages, adding watermarks, encrypting or
|
|
7
7
|
decrypting PDFs, filling PDF forms, extracting images, and OCR on scanned
|
|
8
|
-
PDFs
|
|
9
|
-
|
|
10
|
-
"merge PDFs", "split PDF", "protect PDF", "fill a form", or any
|
|
11
|
-
PDF-related task — even if not explicitly stated as such.
|
|
12
|
-
|
|
8
|
+
PDFs. Use whenever the user mentions .pdf, "create a report", "generate a
|
|
9
|
+
document", "extract from PDF", "merge PDFs", or any PDF-related task.
|
|
13
10
|
license: Proprietary. LICENSE.txt has complete terms
|
|
14
11
|
---
|
|
15
12
|
|
|
16
|
-
# PDF Processing
|
|
13
|
+
# PDF — Professional Document Creation & Processing
|
|
17
14
|
|
|
18
|
-
##
|
|
15
|
+
## Design Philosophy
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
PDF form, read references/FORMS.md and follow its instructions.
|
|
17
|
+
> Every PDF generated by BluMa must look like it was made by a professional
|
|
18
|
+
> design agency, not a script. Typography, spacing, color, and hierarchy are
|
|
19
|
+
> not optional — they are the foundation of a credible document.
|
|
24
20
|
|
|
25
|
-
##
|
|
21
|
+
## MANDATORY: Professional Style System
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
Every PDF you create MUST use a consistent design system. Define these at the
|
|
24
|
+
top of every script BEFORE building any content.
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
reader = PdfReader("document.pdf")
|
|
32
|
-
print(f"Pages: {len(reader.pages)}")
|
|
26
|
+
### Color Palette
|
|
33
27
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
```python
|
|
29
|
+
from reportlab.lib.colors import HexColor
|
|
30
|
+
|
|
31
|
+
COLORS = {
|
|
32
|
+
'primary': HexColor('#1B2A4A'), # Dark navy — titles, headers
|
|
33
|
+
'secondary': HexColor('#2C5F8A'), # Steel blue — subtitles, accents
|
|
34
|
+
'accent': HexColor('#E67E22'), # Warm orange — highlights, callouts
|
|
35
|
+
'text': HexColor('#2D3436'), # Near-black — body text
|
|
36
|
+
'text_light': HexColor('#636E72'), # Gray — captions, footnotes
|
|
37
|
+
'bg_light': HexColor('#F8F9FA'), # Light gray — callout backgrounds
|
|
38
|
+
'bg_accent': HexColor('#EBF5FB'), # Light blue — info boxes
|
|
39
|
+
'border': HexColor('#BDC3C7'), # Subtle border
|
|
40
|
+
'white': HexColor('#FFFFFF'),
|
|
41
|
+
'success': HexColor('#27AE60'), # Green — positive indicators
|
|
42
|
+
'danger': HexColor('#E74C3C'), # Red — warnings
|
|
43
|
+
}
|
|
38
44
|
```
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
### pypdf — Basic Operations
|
|
46
|
+
### Typography
|
|
43
47
|
|
|
44
|
-
#### Merge PDFs
|
|
45
48
|
```python
|
|
46
|
-
from
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
from reportlab.lib.styles import ParagraphStyle
|
|
50
|
+
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
|
|
51
|
+
|
|
52
|
+
FONT_BODY = 'Helvetica'
|
|
53
|
+
FONT_BOLD = 'Helvetica-Bold'
|
|
54
|
+
FONT_ITALIC = 'Helvetica-Oblique'
|
|
55
|
+
FONT_MONO = 'Courier'
|
|
56
|
+
|
|
57
|
+
STYLES = {
|
|
58
|
+
'doc_title': ParagraphStyle(
|
|
59
|
+
'DocTitle', fontName=FONT_BOLD, fontSize=28, leading=34,
|
|
60
|
+
textColor=COLORS['primary'], alignment=TA_CENTER,
|
|
61
|
+
spaceAfter=6,
|
|
62
|
+
),
|
|
63
|
+
'doc_subtitle': ParagraphStyle(
|
|
64
|
+
'DocSubtitle', fontName=FONT_BODY, fontSize=14, leading=18,
|
|
65
|
+
textColor=COLORS['secondary'], alignment=TA_CENTER,
|
|
66
|
+
spaceAfter=30,
|
|
67
|
+
),
|
|
68
|
+
'h1': ParagraphStyle(
|
|
69
|
+
'H1', fontName=FONT_BOLD, fontSize=20, leading=26,
|
|
70
|
+
textColor=COLORS['primary'], spaceBefore=28, spaceAfter=12,
|
|
71
|
+
borderWidth=0, borderPadding=0,
|
|
72
|
+
),
|
|
73
|
+
'h2': ParagraphStyle(
|
|
74
|
+
'H2', fontName=FONT_BOLD, fontSize=15, leading=20,
|
|
75
|
+
textColor=COLORS['secondary'], spaceBefore=20, spaceAfter=8,
|
|
76
|
+
),
|
|
77
|
+
'h3': ParagraphStyle(
|
|
78
|
+
'H3', fontName=FONT_BOLD, fontSize=12, leading=16,
|
|
79
|
+
textColor=COLORS['text'], spaceBefore=14, spaceAfter=6,
|
|
80
|
+
),
|
|
81
|
+
'body': ParagraphStyle(
|
|
82
|
+
'Body', fontName=FONT_BODY, fontSize=10.5, leading=15,
|
|
83
|
+
textColor=COLORS['text'], alignment=TA_JUSTIFY,
|
|
84
|
+
spaceBefore=2, spaceAfter=8,
|
|
85
|
+
),
|
|
86
|
+
'caption': ParagraphStyle(
|
|
87
|
+
'Caption', fontName=FONT_ITALIC, fontSize=9, leading=12,
|
|
88
|
+
textColor=COLORS['text_light'], alignment=TA_CENTER,
|
|
89
|
+
spaceBefore=4, spaceAfter=12,
|
|
90
|
+
),
|
|
91
|
+
'bullet': ParagraphStyle(
|
|
92
|
+
'Bullet', fontName=FONT_BODY, fontSize=10.5, leading=15,
|
|
93
|
+
textColor=COLORS['text'], leftIndent=24, bulletIndent=12,
|
|
94
|
+
spaceBefore=2, spaceAfter=4,
|
|
95
|
+
),
|
|
96
|
+
'code': ParagraphStyle(
|
|
97
|
+
'Code', fontName=FONT_MONO, fontSize=9, leading=13,
|
|
98
|
+
textColor=COLORS['text'], backColor=COLORS['bg_light'],
|
|
99
|
+
leftIndent=12, rightIndent=12,
|
|
100
|
+
spaceBefore=6, spaceAfter=6,
|
|
101
|
+
borderWidth=0.5, borderColor=COLORS['border'],
|
|
102
|
+
borderPadding=8,
|
|
103
|
+
),
|
|
104
|
+
'footer': ParagraphStyle(
|
|
105
|
+
'Footer', fontName=FONT_BODY, fontSize=8, leading=10,
|
|
106
|
+
textColor=COLORS['text_light'], alignment=TA_CENTER,
|
|
107
|
+
),
|
|
108
|
+
}
|
|
56
109
|
```
|
|
57
110
|
|
|
58
|
-
|
|
59
|
-
```python
|
|
60
|
-
reader = PdfReader("input.pdf")
|
|
61
|
-
for i, page in enumerate(reader.pages):
|
|
62
|
-
writer = PdfWriter()
|
|
63
|
-
writer.add_page(page)
|
|
64
|
-
with open(f"page_{i+1}.pdf", "wb") as output:
|
|
65
|
-
writer.write(output)
|
|
66
|
-
```
|
|
111
|
+
### Page Layout
|
|
67
112
|
|
|
68
|
-
#### Extract Metadata
|
|
69
113
|
```python
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
114
|
+
from reportlab.lib.pagesizes import A4
|
|
115
|
+
from reportlab.lib.units import cm, mm
|
|
116
|
+
|
|
117
|
+
PAGE_WIDTH, PAGE_HEIGHT = A4
|
|
118
|
+
MARGIN_LEFT = 2.5 * cm
|
|
119
|
+
MARGIN_RIGHT = 2.5 * cm
|
|
120
|
+
MARGIN_TOP = 2.5 * cm
|
|
121
|
+
MARGIN_BOTTOM = 2.5 * cm
|
|
122
|
+
CONTENT_WIDTH = PAGE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
|
|
76
123
|
```
|
|
77
124
|
|
|
78
|
-
|
|
79
|
-
```python
|
|
80
|
-
reader = PdfReader("input.pdf")
|
|
81
|
-
writer = PdfWriter()
|
|
82
|
-
|
|
83
|
-
page = reader.pages[0]
|
|
84
|
-
page.rotate(90) # Rotate 90 degrees clockwise
|
|
85
|
-
writer.add_page(page)
|
|
125
|
+
## Document Structure Template
|
|
86
126
|
|
|
87
|
-
|
|
88
|
-
writer.write(output)
|
|
89
|
-
```
|
|
127
|
+
Every professional PDF MUST follow this structure:
|
|
90
128
|
|
|
91
|
-
###
|
|
129
|
+
### 1. Cover Page
|
|
92
130
|
|
|
93
|
-
#### Extract Text with Layout
|
|
94
131
|
```python
|
|
95
|
-
import
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
from reportlab.platypus import (
|
|
133
|
+
SimpleDocTemplate, Paragraph, Spacer, PageBreak,
|
|
134
|
+
Table, TableStyle, Image, HRFlowable
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def build_cover(story, title, subtitle=None, author=None, date=None):
|
|
138
|
+
story.append(Spacer(1, 6 * cm))
|
|
139
|
+
|
|
140
|
+
story.append(Paragraph(title, STYLES['doc_title']))
|
|
141
|
+
|
|
142
|
+
story.append(Spacer(1, 0.5 * cm))
|
|
143
|
+
story.append(HRFlowable(
|
|
144
|
+
width='40%', thickness=2, color=COLORS['accent'],
|
|
145
|
+
spaceAfter=20, spaceBefore=10,
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
if subtitle:
|
|
149
|
+
story.append(Paragraph(subtitle, STYLES['doc_subtitle']))
|
|
150
|
+
|
|
151
|
+
story.append(Spacer(1, 3 * cm))
|
|
152
|
+
|
|
153
|
+
if author:
|
|
154
|
+
meta_style = ParagraphStyle(
|
|
155
|
+
'Meta', fontName=FONT_BODY, fontSize=11,
|
|
156
|
+
textColor=COLORS['text_light'], alignment=TA_CENTER,
|
|
157
|
+
spaceAfter=4,
|
|
158
|
+
)
|
|
159
|
+
story.append(Paragraph(author, meta_style))
|
|
160
|
+
|
|
161
|
+
if date:
|
|
162
|
+
date_style = ParagraphStyle(
|
|
163
|
+
'Date', fontName=FONT_BODY, fontSize=10,
|
|
164
|
+
textColor=COLORS['secondary'], alignment=TA_CENTER,
|
|
165
|
+
)
|
|
166
|
+
story.append(Paragraph(date, date_style))
|
|
167
|
+
|
|
168
|
+
story.append(PageBreak())
|
|
101
169
|
```
|
|
102
170
|
|
|
103
|
-
|
|
104
|
-
```python
|
|
105
|
-
with pdfplumber.open("document.pdf") as pdf:
|
|
106
|
-
for i, page in enumerate(pdf.pages):
|
|
107
|
-
tables = page.extract_tables()
|
|
108
|
-
for j, table in enumerate(tables):
|
|
109
|
-
print(f"Table {j+1} on page {i+1}:")
|
|
110
|
-
for row in table:
|
|
111
|
-
print(row)
|
|
112
|
-
```
|
|
171
|
+
### 2. Header and Footer
|
|
113
172
|
|
|
114
|
-
#### Advanced Table Extraction
|
|
115
173
|
```python
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
174
|
+
def header_footer(canvas_obj, doc):
|
|
175
|
+
canvas_obj.saveState()
|
|
176
|
+
|
|
177
|
+
# Header line
|
|
178
|
+
canvas_obj.setStrokeColor(COLORS['secondary'])
|
|
179
|
+
canvas_obj.setLineWidth(1.5)
|
|
180
|
+
y_header = PAGE_HEIGHT - 1.5 * cm
|
|
181
|
+
canvas_obj.line(MARGIN_LEFT, y_header, PAGE_WIDTH - MARGIN_RIGHT, y_header)
|
|
182
|
+
|
|
183
|
+
# Header text
|
|
184
|
+
canvas_obj.setFont(FONT_BODY, 8)
|
|
185
|
+
canvas_obj.setFillColor(COLORS['text_light'])
|
|
186
|
+
canvas_obj.drawString(MARGIN_LEFT, y_header + 4, doc.title or '')
|
|
187
|
+
|
|
188
|
+
# Footer line
|
|
189
|
+
y_footer = 1.2 * cm
|
|
190
|
+
canvas_obj.setStrokeColor(COLORS['border'])
|
|
191
|
+
canvas_obj.setLineWidth(0.5)
|
|
192
|
+
canvas_obj.line(MARGIN_LEFT, y_footer + 8, PAGE_WIDTH - MARGIN_RIGHT, y_footer + 8)
|
|
193
|
+
|
|
194
|
+
# Page number
|
|
195
|
+
canvas_obj.setFont(FONT_BODY, 8)
|
|
196
|
+
canvas_obj.setFillColor(COLORS['text_light'])
|
|
197
|
+
page_text = f"Page {doc.page}"
|
|
198
|
+
canvas_obj.drawCentredString(PAGE_WIDTH / 2, y_footer, page_text)
|
|
199
|
+
|
|
200
|
+
canvas_obj.restoreState()
|
|
130
201
|
```
|
|
131
202
|
|
|
132
|
-
###
|
|
203
|
+
### 3. Table of Contents
|
|
133
204
|
|
|
134
|
-
#### Basic PDF Creation
|
|
135
205
|
```python
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
206
|
+
def build_toc(story, sections):
|
|
207
|
+
story.append(Paragraph("Table of Contents", STYLES['h1']))
|
|
208
|
+
story.append(Spacer(1, 0.5 * cm))
|
|
209
|
+
|
|
210
|
+
toc_style = ParagraphStyle(
|
|
211
|
+
'TOCEntry', fontName=FONT_BODY, fontSize=11, leading=20,
|
|
212
|
+
textColor=COLORS['text'],
|
|
213
|
+
)
|
|
214
|
+
toc_bold = ParagraphStyle(
|
|
215
|
+
'TOCBold', fontName=FONT_BOLD, fontSize=11, leading=20,
|
|
216
|
+
textColor=COLORS['primary'],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
for i, section in enumerate(sections, 1):
|
|
220
|
+
title = section['title']
|
|
221
|
+
page = section.get('page', '')
|
|
222
|
+
is_main = section.get('level', 1) == 1
|
|
223
|
+
|
|
224
|
+
style = toc_bold if is_main else toc_style
|
|
225
|
+
indent = 0 if is_main else 20
|
|
226
|
+
|
|
227
|
+
entry = Table(
|
|
228
|
+
[[Paragraph(f"{' ' * (indent // 4)}{i}. {title}" if is_main else f" {title}", style),
|
|
229
|
+
Paragraph(str(page), style)]],
|
|
230
|
+
colWidths=[CONTENT_WIDTH - 40, 40],
|
|
231
|
+
)
|
|
232
|
+
entry.setStyle(TableStyle([
|
|
233
|
+
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
|
|
234
|
+
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
235
|
+
('LINEBELOW', (0, 0), (-1, -1), 0.5, COLORS['bg_light']),
|
|
236
|
+
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
237
|
+
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
238
|
+
]))
|
|
239
|
+
story.append(entry)
|
|
240
|
+
|
|
241
|
+
story.append(PageBreak())
|
|
150
242
|
```
|
|
151
243
|
|
|
152
|
-
|
|
153
|
-
```python
|
|
154
|
-
from reportlab.lib.pagesizes import A4
|
|
155
|
-
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
|
|
156
|
-
from reportlab.lib.styles import getSampleStyleSheet
|
|
157
|
-
|
|
158
|
-
doc = SimpleDocTemplate("report.pdf", pagesize=A4)
|
|
159
|
-
styles = getSampleStyleSheet()
|
|
160
|
-
story = []
|
|
161
|
-
|
|
162
|
-
# Add content
|
|
163
|
-
title = Paragraph("Report Title", styles['Title'])
|
|
164
|
-
story.append(title)
|
|
165
|
-
story.append(Spacer(1, 12))
|
|
166
|
-
|
|
167
|
-
body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
|
|
168
|
-
story.append(body)
|
|
169
|
-
story.append(PageBreak())
|
|
244
|
+
### 4. Section Headers with Accent Bar
|
|
170
245
|
|
|
171
|
-
|
|
172
|
-
story
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
246
|
+
```python
|
|
247
|
+
def add_section(story, number, title):
|
|
248
|
+
header_table = Table(
|
|
249
|
+
[[Paragraph(f"{number}.", ParagraphStyle(
|
|
250
|
+
'SNum', fontName=FONT_BOLD, fontSize=20,
|
|
251
|
+
textColor=COLORS['accent'],
|
|
252
|
+
)),
|
|
253
|
+
Paragraph(title, STYLES['h1'])]],
|
|
254
|
+
colWidths=[35, CONTENT_WIDTH - 35],
|
|
255
|
+
)
|
|
256
|
+
header_table.setStyle(TableStyle([
|
|
257
|
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
258
|
+
('TOPPADDING', (0, 0), (-1, -1), 0),
|
|
259
|
+
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
|
|
260
|
+
]))
|
|
261
|
+
story.append(header_table)
|
|
262
|
+
|
|
263
|
+
story.append(HRFlowable(
|
|
264
|
+
width='100%', thickness=1, color=COLORS['secondary'],
|
|
265
|
+
spaceAfter=12,
|
|
266
|
+
))
|
|
177
267
|
```
|
|
178
268
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
**IMPORTANT**: Never use Unicode subscript/superscript characters (₀₁₂₃₄₅₆₇₈₉,
|
|
182
|
-
⁰¹²³⁴⁵⁶⁷⁸⁹) in ReportLab PDFs. The built-in fonts do not include these glyphs,
|
|
183
|
-
causing them to render as solid black boxes.
|
|
269
|
+
### 5. Callout / Info Boxes
|
|
184
270
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
271
|
+
```python
|
|
272
|
+
def add_callout(story, title, text, box_type='info'):
|
|
273
|
+
bg = COLORS['bg_accent'] if box_type == 'info' else COLORS['bg_light']
|
|
274
|
+
border = COLORS['secondary'] if box_type == 'info' else COLORS['accent']
|
|
275
|
+
icon = '💡' if box_type == 'info' else '⚠️'
|
|
276
|
+
|
|
277
|
+
content = [
|
|
278
|
+
[Paragraph(
|
|
279
|
+
f"<b>{icon} {title}</b>",
|
|
280
|
+
ParagraphStyle('CalloutTitle', fontName=FONT_BOLD,
|
|
281
|
+
fontSize=10, textColor=COLORS['primary'],
|
|
282
|
+
spaceAfter=4)
|
|
283
|
+
)],
|
|
284
|
+
[Paragraph(
|
|
285
|
+
text,
|
|
286
|
+
ParagraphStyle('CalloutBody', fontName=FONT_BODY,
|
|
287
|
+
fontSize=9.5, leading=14,
|
|
288
|
+
textColor=COLORS['text'])
|
|
289
|
+
)],
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
table = Table(content, colWidths=[CONTENT_WIDTH - 20])
|
|
293
|
+
table.setStyle(TableStyle([
|
|
294
|
+
('BACKGROUND', (0, 0), (-1, -1), bg),
|
|
295
|
+
('BOX', (0, 0), (-1, -1), 1.5, border),
|
|
296
|
+
('LEFTPADDING', (0, 0), (-1, -1), 14),
|
|
297
|
+
('RIGHTPADDING', (0, 0), (-1, -1), 14),
|
|
298
|
+
('TOPPADDING', (0, 0), (0, 0), 10),
|
|
299
|
+
('BOTTOMPADDING', (-1, -1), (-1, -1), 10),
|
|
300
|
+
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
301
|
+
]))
|
|
302
|
+
story.append(Spacer(1, 8))
|
|
303
|
+
story.append(table)
|
|
304
|
+
story.append(Spacer(1, 8))
|
|
305
|
+
```
|
|
192
306
|
|
|
193
|
-
|
|
194
|
-
chemical = Paragraph("H<sub>2</sub>O", styles['Normal'])
|
|
307
|
+
### 6. Professional Tables
|
|
195
308
|
|
|
196
|
-
|
|
197
|
-
|
|
309
|
+
```python
|
|
310
|
+
def add_table(story, headers, rows, col_widths=None):
|
|
311
|
+
data = [headers] + rows
|
|
312
|
+
|
|
313
|
+
if not col_widths:
|
|
314
|
+
col_widths = [CONTENT_WIDTH / len(headers)] * len(headers)
|
|
315
|
+
|
|
316
|
+
table = Table(data, colWidths=col_widths, repeatRows=1)
|
|
317
|
+
table.setStyle(TableStyle([
|
|
318
|
+
# Header
|
|
319
|
+
('BACKGROUND', (0, 0), (-1, 0), COLORS['primary']),
|
|
320
|
+
('TEXTCOLOR', (0, 0), (-1, 0), COLORS['white']),
|
|
321
|
+
('FONTNAME', (0, 0), (-1, 0), FONT_BOLD),
|
|
322
|
+
('FONTSIZE', (0, 0), (-1, 0), 10),
|
|
323
|
+
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
|
|
324
|
+
('TOPPADDING', (0, 0), (-1, 0), 10),
|
|
325
|
+
|
|
326
|
+
# Body rows
|
|
327
|
+
('FONTNAME', (0, 1), (-1, -1), FONT_BODY),
|
|
328
|
+
('FONTSIZE', (0, 1), (-1, -1), 9.5),
|
|
329
|
+
('TEXTCOLOR', (0, 1), (-1, -1), COLORS['text']),
|
|
330
|
+
('TOPPADDING', (0, 1), (-1, -1), 7),
|
|
331
|
+
('BOTTOMPADDING', (0, 1), (-1, -1), 7),
|
|
332
|
+
|
|
333
|
+
# Alternating row colors
|
|
334
|
+
*[('BACKGROUND', (0, i), (-1, i), COLORS['bg_light'])
|
|
335
|
+
for i in range(1, len(data), 2)],
|
|
336
|
+
|
|
337
|
+
# Grid
|
|
338
|
+
('LINEBELOW', (0, 0), (-1, 0), 1.5, COLORS['secondary']),
|
|
339
|
+
('LINEBELOW', (0, 1), (-1, -2), 0.5, COLORS['border']),
|
|
340
|
+
('LINEBELOW', (0, -1), (-1, -1), 1, COLORS['secondary']),
|
|
341
|
+
|
|
342
|
+
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
|
343
|
+
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
344
|
+
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
|
345
|
+
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
|
346
|
+
]))
|
|
347
|
+
story.append(table)
|
|
348
|
+
story.append(Spacer(1, 12))
|
|
198
349
|
```
|
|
199
350
|
|
|
200
|
-
|
|
201
|
-
position rather than using Unicode subscripts/superscripts.
|
|
202
|
-
|
|
203
|
-
## Command-Line Tools
|
|
351
|
+
### 7. Building the Document
|
|
204
352
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
353
|
+
```python
|
|
354
|
+
from datetime import date
|
|
355
|
+
|
|
356
|
+
doc = SimpleDocTemplate(
|
|
357
|
+
"output.pdf",
|
|
358
|
+
pagesize=A4,
|
|
359
|
+
leftMargin=MARGIN_LEFT,
|
|
360
|
+
rightMargin=MARGIN_RIGHT,
|
|
361
|
+
topMargin=MARGIN_TOP,
|
|
362
|
+
bottomMargin=MARGIN_BOTTOM,
|
|
363
|
+
title="Document Title",
|
|
364
|
+
author="BluMa",
|
|
365
|
+
)
|
|
209
366
|
|
|
210
|
-
|
|
211
|
-
pdftotext -layout input.pdf output.txt
|
|
367
|
+
story = []
|
|
212
368
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
```
|
|
369
|
+
build_cover(story, "Document Title", subtitle="A Professional Report",
|
|
370
|
+
author="Generated by BluMa", date=date.today().strftime('%B %Y'))
|
|
216
371
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
372
|
+
build_toc(story, [
|
|
373
|
+
{'title': 'Introduction', 'level': 1, 'page': 3},
|
|
374
|
+
{'title': 'Analysis', 'level': 1, 'page': 5},
|
|
375
|
+
])
|
|
221
376
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
377
|
+
add_section(story, 1, "Introduction")
|
|
378
|
+
story.append(Paragraph("Body text here...", STYLES['body']))
|
|
379
|
+
add_callout(story, "Key Insight", "Important information goes here.")
|
|
225
380
|
|
|
226
|
-
|
|
227
|
-
|
|
381
|
+
add_section(story, 2, "Analysis")
|
|
382
|
+
add_table(story,
|
|
383
|
+
['Metric', 'Value', 'Change'],
|
|
384
|
+
[['Revenue', '$1.2M', '+15%'], ['Users', '50K', '+22%']],
|
|
385
|
+
)
|
|
228
386
|
|
|
229
|
-
|
|
230
|
-
|
|
387
|
+
doc.build(story, onFirstPage=lambda c, d: None,
|
|
388
|
+
onLaterPages=header_footer)
|
|
231
389
|
```
|
|
232
390
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
391
|
+
## Design Rules (MANDATORY)
|
|
392
|
+
|
|
393
|
+
1. **Never use default reportlab styles** — always create custom styles
|
|
394
|
+
2. **Font hierarchy**: Title 28pt > H1 20pt > H2 15pt > H3 12pt > Body 10.5pt
|
|
395
|
+
3. **Line height**: Always 1.4x font size minimum (e.g. 10.5pt font → 15pt leading)
|
|
396
|
+
4. **Margins**: Minimum 2.5cm on all sides
|
|
397
|
+
5. **Color**: Maximum 3-4 colors per document. Use the palette above
|
|
398
|
+
6. **Tables**: Always have header styling, alternating rows, proper padding
|
|
399
|
+
7. **Spacing**: Generous whitespace. Use `Spacer` between sections
|
|
400
|
+
8. **Cover page**: Every document with 3+ pages MUST have a cover page
|
|
401
|
+
9. **Headers/Footers**: Every multi-page document MUST have page numbers
|
|
402
|
+
10. **No raw `drawString`**: Use `Paragraph` with styles for all text
|
|
403
|
+
11. **Alignment**: Body text justified, titles centered, code left-aligned
|
|
404
|
+
12. **Callout boxes**: Use for key insights, warnings, important notes
|
|
405
|
+
13. **Section dividers**: Use `HRFlowable` or colored bars between major sections
|
|
406
|
+
14. **Subscripts/Superscripts**: Use `<sub>` and `<super>` tags, NEVER Unicode chars
|
|
407
|
+
|
|
408
|
+
## Reading & Extracting from PDFs
|
|
409
|
+
|
|
410
|
+
### Extract Text
|
|
411
|
+
```python
|
|
412
|
+
import pdfplumber
|
|
240
413
|
|
|
241
|
-
|
|
242
|
-
|
|
414
|
+
with pdfplumber.open("document.pdf") as pdf:
|
|
415
|
+
for page in pdf.pages:
|
|
416
|
+
print(page.extract_text())
|
|
243
417
|
```
|
|
244
418
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
### Extract Text from Scanned PDFs
|
|
419
|
+
### Extract Tables to DataFrame
|
|
248
420
|
```python
|
|
249
|
-
|
|
250
|
-
import
|
|
251
|
-
from pdf2image import convert_from_path
|
|
252
|
-
|
|
253
|
-
# Convert PDF to images
|
|
254
|
-
images = convert_from_path('scanned.pdf')
|
|
421
|
+
import pandas as pd
|
|
422
|
+
import pdfplumber
|
|
255
423
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
for
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
424
|
+
with pdfplumber.open("document.pdf") as pdf:
|
|
425
|
+
tables = []
|
|
426
|
+
for page in pdf.pages:
|
|
427
|
+
for table in page.extract_tables():
|
|
428
|
+
if table:
|
|
429
|
+
df = pd.DataFrame(table[1:], columns=table[0])
|
|
430
|
+
tables.append(df)
|
|
262
431
|
|
|
263
|
-
|
|
432
|
+
if tables:
|
|
433
|
+
combined = pd.concat(tables, ignore_index=True)
|
|
434
|
+
combined.to_excel("extracted.xlsx", index=False)
|
|
264
435
|
```
|
|
265
436
|
|
|
266
|
-
|
|
267
|
-
```python
|
|
268
|
-
from pypdf import PdfReader, PdfWriter
|
|
437
|
+
## Merge, Split, Protect
|
|
269
438
|
|
|
270
|
-
|
|
271
|
-
|
|
439
|
+
### Merge PDFs
|
|
440
|
+
```python
|
|
441
|
+
from pypdf import PdfWriter, PdfReader
|
|
272
442
|
|
|
273
|
-
# Apply to all pages
|
|
274
|
-
reader = PdfReader("document.pdf")
|
|
275
443
|
writer = PdfWriter()
|
|
444
|
+
for f in ["doc1.pdf", "doc2.pdf"]:
|
|
445
|
+
for page in PdfReader(f).pages:
|
|
446
|
+
writer.add_page(page)
|
|
276
447
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
writer.add_page(page)
|
|
280
|
-
|
|
281
|
-
with open("watermarked.pdf", "wb") as output:
|
|
282
|
-
writer.write(output)
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
### Extract Images
|
|
286
|
-
```bash
|
|
287
|
-
# Using pdfimages (poppler-utils)
|
|
288
|
-
pdfimages -j input.pdf output_prefix
|
|
289
|
-
# Extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.
|
|
448
|
+
with open("merged.pdf", "wb") as out:
|
|
449
|
+
writer.write(out)
|
|
290
450
|
```
|
|
291
451
|
|
|
292
452
|
### Password Protection
|
|
@@ -295,33 +455,48 @@ from pypdf import PdfReader, PdfWriter
|
|
|
295
455
|
|
|
296
456
|
reader = PdfReader("input.pdf")
|
|
297
457
|
writer = PdfWriter()
|
|
298
|
-
|
|
299
458
|
for page in reader.pages:
|
|
300
459
|
writer.add_page(page)
|
|
460
|
+
writer.encrypt("user_password", "owner_password")
|
|
301
461
|
|
|
302
|
-
|
|
303
|
-
writer.
|
|
462
|
+
with open("protected.pdf", "wb") as out:
|
|
463
|
+
writer.write(out)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## OCR (Scanned PDFs)
|
|
467
|
+
```python
|
|
468
|
+
import pytesseract
|
|
469
|
+
from pdf2image import convert_from_path
|
|
304
470
|
|
|
305
|
-
|
|
306
|
-
|
|
471
|
+
images = convert_from_path('scanned.pdf')
|
|
472
|
+
text = ""
|
|
473
|
+
for i, img in enumerate(images):
|
|
474
|
+
text += f"--- Page {i+1} ---\n"
|
|
475
|
+
text += pytesseract.image_to_string(img, lang="por+eng")
|
|
476
|
+
text += "\n\n"
|
|
307
477
|
```
|
|
308
478
|
|
|
309
479
|
## Quick Reference
|
|
310
480
|
|
|
311
|
-
| Task
|
|
312
|
-
|
|
313
|
-
|
|
|
314
|
-
|
|
|
315
|
-
|
|
|
316
|
-
|
|
|
317
|
-
|
|
|
318
|
-
|
|
|
319
|
-
|
|
|
320
|
-
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
-
|
|
327
|
-
-
|
|
481
|
+
| Task | Tool | Key API |
|
|
482
|
+
|------|------|---------|
|
|
483
|
+
| Create PDF | reportlab | `SimpleDocTemplate` + `Platypus` |
|
|
484
|
+
| Read text | pdfplumber | `page.extract_text()` |
|
|
485
|
+
| Read tables | pdfplumber | `page.extract_tables()` |
|
|
486
|
+
| Merge | pypdf | `PdfWriter.add_page()` |
|
|
487
|
+
| Split | pypdf | One page per `PdfWriter` |
|
|
488
|
+
| Protect | pypdf | `writer.encrypt()` |
|
|
489
|
+
| Watermark | pypdf | `page.merge_page()` |
|
|
490
|
+
| OCR | pytesseract | `image_to_string()` |
|
|
491
|
+
| Forms | pypdf | See references/FORMS.md |
|
|
492
|
+
| CLI merge | qpdf | `qpdf --empty --pages` |
|
|
493
|
+
|
|
494
|
+
## Available References
|
|
495
|
+
|
|
496
|
+
- REFERENCE.md: Advanced reportlab features, JavaScript pdf-lib, CLI tools
|
|
497
|
+
- FORMS.md: Filling and creating PDF forms
|
|
498
|
+
|
|
499
|
+
## Available Scripts
|
|
500
|
+
|
|
501
|
+
- create_report.py: Professional report template with cover, TOC, styled sections
|
|
502
|
+
- merge_pdfs.py: Merge multiple PDFs into one
|
|
@@ -1,59 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
1
2
|
"""
|
|
2
|
-
|
|
3
|
+
Professional PDF Report Generator — BluMa Template
|
|
3
4
|
|
|
4
5
|
Usage:
|
|
5
|
-
python create_report.py --
|
|
6
|
-
|
|
6
|
+
python create_report.py [--title TITLE] [--subtitle SUBTITLE] [--output FILE]
|
|
7
|
+
|
|
8
|
+
Generates a professional report PDF with cover page, table of contents,
|
|
9
|
+
styled sections, tables, and callout boxes.
|
|
7
10
|
"""
|
|
11
|
+
|
|
8
12
|
import argparse
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
13
|
+
from datetime import date
|
|
14
|
+
|
|
15
|
+
from reportlab.lib.pagesizes import A4
|
|
16
|
+
from reportlab.lib.units import cm
|
|
17
|
+
from reportlab.lib.colors import HexColor
|
|
18
|
+
from reportlab.lib.styles import ParagraphStyle
|
|
19
|
+
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
|
|
20
|
+
from reportlab.platypus import (
|
|
21
|
+
SimpleDocTemplate, Paragraph, Spacer, PageBreak,
|
|
22
|
+
Table, TableStyle, HRFlowable,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Color Palette ──────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
COLORS = {
|
|
29
|
+
'primary': HexColor('#1B2A4A'),
|
|
30
|
+
'secondary': HexColor('#2C5F8A'),
|
|
31
|
+
'accent': HexColor('#E67E22'),
|
|
32
|
+
'text': HexColor('#2D3436'),
|
|
33
|
+
'text_light': HexColor('#636E72'),
|
|
34
|
+
'bg_light': HexColor('#F8F9FA'),
|
|
35
|
+
'bg_accent': HexColor('#EBF5FB'),
|
|
36
|
+
'border': HexColor('#BDC3C7'),
|
|
37
|
+
'white': HexColor('#FFFFFF'),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# ── Typography ─────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
FONT = 'Helvetica'
|
|
43
|
+
FONT_B = 'Helvetica-Bold'
|
|
44
|
+
FONT_I = 'Helvetica-Oblique'
|
|
45
|
+
|
|
46
|
+
PAGE_W, PAGE_H = A4
|
|
47
|
+
M = 2.5 * cm
|
|
48
|
+
CW = PAGE_W - 2 * M
|
|
49
|
+
|
|
50
|
+
S = {
|
|
51
|
+
'title': ParagraphStyle('T', fontName=FONT_B, fontSize=28, leading=34,
|
|
52
|
+
textColor=COLORS['primary'], alignment=TA_CENTER, spaceAfter=6),
|
|
53
|
+
'subtitle': ParagraphStyle('ST', fontName=FONT, fontSize=14, leading=18,
|
|
54
|
+
textColor=COLORS['secondary'], alignment=TA_CENTER, spaceAfter=30),
|
|
55
|
+
'h1': ParagraphStyle('H1', fontName=FONT_B, fontSize=20, leading=26,
|
|
56
|
+
textColor=COLORS['primary'], spaceBefore=28, spaceAfter=12),
|
|
57
|
+
'h2': ParagraphStyle('H2', fontName=FONT_B, fontSize=15, leading=20,
|
|
58
|
+
textColor=COLORS['secondary'], spaceBefore=20, spaceAfter=8),
|
|
59
|
+
'body': ParagraphStyle('B', fontName=FONT, fontSize=10.5, leading=15,
|
|
60
|
+
textColor=COLORS['text'], alignment=TA_JUSTIFY,
|
|
61
|
+
spaceBefore=2, spaceAfter=8),
|
|
62
|
+
'bullet': ParagraphStyle('BL', fontName=FONT, fontSize=10.5, leading=15,
|
|
63
|
+
textColor=COLORS['text'], leftIndent=24, bulletIndent=12,
|
|
64
|
+
spaceBefore=2, spaceAfter=4),
|
|
65
|
+
'meta': ParagraphStyle('M', fontName=FONT, fontSize=11,
|
|
66
|
+
textColor=COLORS['text_light'], alignment=TA_CENTER, spaceAfter=4),
|
|
67
|
+
'date': ParagraphStyle('D', fontName=FONT, fontSize=10,
|
|
68
|
+
textColor=COLORS['secondary'], alignment=TA_CENTER),
|
|
69
|
+
'toc': ParagraphStyle('TOC', fontName=FONT, fontSize=11, leading=20,
|
|
70
|
+
textColor=COLORS['text']),
|
|
71
|
+
'toc_b': ParagraphStyle('TOCB', fontName=FONT_B, fontSize=11, leading=20,
|
|
72
|
+
textColor=COLORS['primary']),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Components ─────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def cover(story, title, subtitle=None, author=None):
|
|
79
|
+
story.append(Spacer(1, 6 * cm))
|
|
80
|
+
story.append(Paragraph(title, S['title']))
|
|
81
|
+
story.append(Spacer(1, 0.5 * cm))
|
|
82
|
+
story.append(HRFlowable(width='40%', thickness=2, color=COLORS['accent'],
|
|
83
|
+
spaceAfter=20, spaceBefore=10))
|
|
84
|
+
if subtitle:
|
|
85
|
+
story.append(Paragraph(subtitle, S['subtitle']))
|
|
86
|
+
story.append(Spacer(1, 3 * cm))
|
|
87
|
+
if author:
|
|
88
|
+
story.append(Paragraph(author, S['meta']))
|
|
89
|
+
story.append(Paragraph(date.today().strftime('%B %Y'), S['date']))
|
|
90
|
+
story.append(PageBreak())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def toc(story, sections):
|
|
94
|
+
story.append(Paragraph("Table of Contents", S['h1']))
|
|
95
|
+
story.append(Spacer(1, 0.5 * cm))
|
|
96
|
+
for sec in sections:
|
|
97
|
+
style = S['toc_b'] if sec.get('level', 1) == 1 else S['toc']
|
|
98
|
+
indent = '' if sec.get('level', 1) == 1 else ' '
|
|
99
|
+
row = Table(
|
|
100
|
+
[[Paragraph(f"{indent}{sec['num']}. {sec['title']}" if sec.get('level', 1) == 1
|
|
101
|
+
else f"{indent}{sec['title']}", style),
|
|
102
|
+
Paragraph(str(sec.get('page', '')), style)]],
|
|
103
|
+
colWidths=[CW - 40, 40],
|
|
104
|
+
)
|
|
105
|
+
row.setStyle(TableStyle([
|
|
106
|
+
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
|
|
107
|
+
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
108
|
+
('LINEBELOW', (0, 0), (-1, -1), 0.5, COLORS['bg_light']),
|
|
109
|
+
('TOPPADDING', (0, 0), (-1, -1), 4),
|
|
110
|
+
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
|
111
|
+
]))
|
|
112
|
+
story.append(row)
|
|
113
|
+
story.append(PageBreak())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def section(story, num, title):
|
|
117
|
+
header = Table(
|
|
118
|
+
[[Paragraph(f"{num}.", ParagraphStyle('SN', fontName=FONT_B, fontSize=20,
|
|
119
|
+
textColor=COLORS['accent'])),
|
|
120
|
+
Paragraph(title, S['h1'])]],
|
|
121
|
+
colWidths=[35, CW - 35],
|
|
122
|
+
)
|
|
123
|
+
header.setStyle(TableStyle([
|
|
124
|
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
125
|
+
('TOPPADDING', (0, 0), (-1, -1), 0),
|
|
126
|
+
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
|
|
127
|
+
]))
|
|
128
|
+
story.append(header)
|
|
129
|
+
story.append(HRFlowable(width='100%', thickness=1, color=COLORS['secondary'],
|
|
130
|
+
spaceAfter=12))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def callout(story, title, text, box_type='info'):
|
|
134
|
+
bg = COLORS['bg_accent'] if box_type == 'info' else COLORS['bg_light']
|
|
135
|
+
border = COLORS['secondary'] if box_type == 'info' else COLORS['accent']
|
|
136
|
+
content = [
|
|
137
|
+
[Paragraph(f"<b>{title}</b>",
|
|
138
|
+
ParagraphStyle('CT', fontName=FONT_B, fontSize=10,
|
|
139
|
+
textColor=COLORS['primary'], spaceAfter=4))],
|
|
140
|
+
[Paragraph(text, ParagraphStyle('CB', fontName=FONT, fontSize=9.5,
|
|
141
|
+
leading=14, textColor=COLORS['text']))],
|
|
142
|
+
]
|
|
143
|
+
t = Table(content, colWidths=[CW - 20])
|
|
144
|
+
t.setStyle(TableStyle([
|
|
145
|
+
('BACKGROUND', (0, 0), (-1, -1), bg),
|
|
146
|
+
('BOX', (0, 0), (-1, -1), 1.5, border),
|
|
147
|
+
('LEFTPADDING', (0, 0), (-1, -1), 14),
|
|
148
|
+
('RIGHTPADDING', (0, 0), (-1, -1), 14),
|
|
149
|
+
('TOPPADDING', (0, 0), (0, 0), 10),
|
|
150
|
+
('BOTTOMPADDING', (-1, -1), (-1, -1), 10),
|
|
47
151
|
]))
|
|
48
|
-
|
|
152
|
+
story.append(Spacer(1, 8))
|
|
153
|
+
story.append(t)
|
|
154
|
+
story.append(Spacer(1, 8))
|
|
49
155
|
|
|
50
|
-
doc.build(elements)
|
|
51
|
-
print(f"Report created: {output_pdf} ({len(data)-1} data rows)")
|
|
52
156
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
157
|
+
def pro_table(story, headers, rows, col_widths=None):
|
|
158
|
+
data = [headers] + rows
|
|
159
|
+
if not col_widths:
|
|
160
|
+
col_widths = [CW / len(headers)] * len(headers)
|
|
161
|
+
t = Table(data, colWidths=col_widths, repeatRows=1)
|
|
162
|
+
t.setStyle(TableStyle([
|
|
163
|
+
('BACKGROUND', (0, 0), (-1, 0), COLORS['primary']),
|
|
164
|
+
('TEXTCOLOR', (0, 0), (-1, 0), COLORS['white']),
|
|
165
|
+
('FONTNAME', (0, 0), (-1, 0), FONT_B),
|
|
166
|
+
('FONTSIZE', (0, 0), (-1, 0), 10),
|
|
167
|
+
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
|
|
168
|
+
('TOPPADDING', (0, 0), (-1, 0), 10),
|
|
169
|
+
('FONTNAME', (0, 1), (-1, -1), FONT),
|
|
170
|
+
('FONTSIZE', (0, 1), (-1, -1), 9.5),
|
|
171
|
+
('TEXTCOLOR', (0, 1), (-1, -1), COLORS['text']),
|
|
172
|
+
('TOPPADDING', (0, 1), (-1, -1), 7),
|
|
173
|
+
('BOTTOMPADDING', (0, 1), (-1, -1), 7),
|
|
174
|
+
*[('BACKGROUND', (0, i), (-1, i), COLORS['bg_light'])
|
|
175
|
+
for i in range(1, len(data), 2)],
|
|
176
|
+
('LINEBELOW', (0, 0), (-1, 0), 1.5, COLORS['secondary']),
|
|
177
|
+
('LINEBELOW', (0, 1), (-1, -2), 0.5, COLORS['border']),
|
|
178
|
+
('LINEBELOW', (0, -1), (-1, -1), 1, COLORS['secondary']),
|
|
179
|
+
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
|
180
|
+
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
181
|
+
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
|
182
|
+
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
|
183
|
+
]))
|
|
184
|
+
story.append(t)
|
|
185
|
+
story.append(Spacer(1, 12))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def header_footer(canvas_obj, doc):
|
|
189
|
+
canvas_obj.saveState()
|
|
190
|
+
y_h = PAGE_H - 1.5 * cm
|
|
191
|
+
canvas_obj.setStrokeColor(COLORS['secondary'])
|
|
192
|
+
canvas_obj.setLineWidth(1.5)
|
|
193
|
+
canvas_obj.line(M, y_h, PAGE_W - M, y_h)
|
|
194
|
+
canvas_obj.setFont(FONT, 8)
|
|
195
|
+
canvas_obj.setFillColor(COLORS['text_light'])
|
|
196
|
+
canvas_obj.drawString(M, y_h + 4, doc.title or '')
|
|
197
|
+
|
|
198
|
+
y_f = 1.2 * cm
|
|
199
|
+
canvas_obj.setStrokeColor(COLORS['border'])
|
|
200
|
+
canvas_obj.setLineWidth(0.5)
|
|
201
|
+
canvas_obj.line(M, y_f + 8, PAGE_W - M, y_f + 8)
|
|
202
|
+
canvas_obj.drawCentredString(PAGE_W / 2, y_f, f"Page {doc.page}")
|
|
203
|
+
canvas_obj.restoreState()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Main ───────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def main():
|
|
209
|
+
parser = argparse.ArgumentParser(description='Generate professional PDF report')
|
|
210
|
+
parser.add_argument('--title', default='Sample Report')
|
|
211
|
+
parser.add_argument('--subtitle', default='A Professional Document')
|
|
212
|
+
parser.add_argument('--output', default='report.pdf')
|
|
58
213
|
args = parser.parse_args()
|
|
59
|
-
|
|
214
|
+
|
|
215
|
+
doc = SimpleDocTemplate(
|
|
216
|
+
args.output, pagesize=A4,
|
|
217
|
+
leftMargin=M, rightMargin=M, topMargin=M, bottomMargin=M,
|
|
218
|
+
title=args.title, author='BluMa',
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
story = []
|
|
222
|
+
|
|
223
|
+
cover(story, args.title, args.subtitle, "Generated by BluMa")
|
|
224
|
+
|
|
225
|
+
toc(story, [
|
|
226
|
+
{'num': 1, 'title': 'Introduction', 'level': 1, 'page': 3},
|
|
227
|
+
{'num': 2, 'title': 'Key Findings', 'level': 1, 'page': 4},
|
|
228
|
+
{'num': 3, 'title': 'Conclusion', 'level': 1, 'page': 5},
|
|
229
|
+
])
|
|
230
|
+
|
|
231
|
+
section(story, 1, "Introduction")
|
|
232
|
+
story.append(Paragraph(
|
|
233
|
+
"This document demonstrates the professional PDF generation capabilities "
|
|
234
|
+
"of BluMa. Every element — from typography to color palette — follows a "
|
|
235
|
+
"consistent design system that ensures readability and visual appeal.",
|
|
236
|
+
S['body'],
|
|
237
|
+
))
|
|
238
|
+
story.append(Paragraph(
|
|
239
|
+
"The design system uses a navy and steel blue palette with warm orange "
|
|
240
|
+
"accents, Helvetica typography with a clear hierarchy, and generous "
|
|
241
|
+
"whitespace for a clean, modern look.",
|
|
242
|
+
S['body'],
|
|
243
|
+
))
|
|
244
|
+
|
|
245
|
+
callout(story, "Key Insight",
|
|
246
|
+
"Professional documents communicate credibility before the reader "
|
|
247
|
+
"processes a single word. Design is the first impression.")
|
|
248
|
+
|
|
249
|
+
section(story, 2, "Key Findings")
|
|
250
|
+
story.append(Paragraph("Analysis results are summarized in the table below:", S['body']))
|
|
251
|
+
pro_table(story,
|
|
252
|
+
['Metric', 'Q1 2026', 'Q2 2026', 'Change'],
|
|
253
|
+
[
|
|
254
|
+
['Revenue', '$1.2M', '$1.5M', '+25%'],
|
|
255
|
+
['Active Users', '45,000', '58,000', '+29%'],
|
|
256
|
+
['Retention', '82%', '87%', '+5pp'],
|
|
257
|
+
['NPS Score', '42', '56', '+14'],
|
|
258
|
+
])
|
|
259
|
+
story.append(Paragraph(
|
|
260
|
+
"All metrics show significant improvement quarter-over-quarter, "
|
|
261
|
+
"driven primarily by the new onboarding flow and retention campaigns.",
|
|
262
|
+
S['body'],
|
|
263
|
+
))
|
|
264
|
+
|
|
265
|
+
story.append(Paragraph("Key highlights:", S['body']))
|
|
266
|
+
for item in [
|
|
267
|
+
"Revenue growth exceeded targets by 10 percentage points",
|
|
268
|
+
"User retention improved across all cohorts",
|
|
269
|
+
"NPS score moved from 'good' to 'excellent' territory",
|
|
270
|
+
]:
|
|
271
|
+
story.append(Paragraph(f"• {item}", S['bullet']))
|
|
272
|
+
|
|
273
|
+
callout(story, "Note", "Growth rates are compared to the same period in the "
|
|
274
|
+
"prior fiscal year, adjusted for seasonality.", box_type='warning')
|
|
275
|
+
|
|
276
|
+
section(story, 3, "Conclusion")
|
|
277
|
+
story.append(Paragraph(
|
|
278
|
+
"The data confirms that the strategic initiatives launched in Q4 2025 "
|
|
279
|
+
"are delivering measurable results. We recommend continuing the current "
|
|
280
|
+
"trajectory while monitoring customer acquisition costs closely.",
|
|
281
|
+
S['body'],
|
|
282
|
+
))
|
|
283
|
+
|
|
284
|
+
doc.build(story, onFirstPage=lambda c, d: None, onLaterPages=header_footer)
|
|
285
|
+
print(f"Report generated: {args.output}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == '__main__':
|
|
289
|
+
main()
|