@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 to make them searchable. Use this skill whenever the user mentions
9
- .pdf, "PDF document", "create a report as PDF", "extract from PDF",
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 Guide
13
+ # PDF — Professional Document Creation & Processing
17
14
 
18
- ## Overview
15
+ ## Design Philosophy
19
16
 
20
- This guide covers essential PDF processing operations using Python libraries
21
- and command-line tools. For advanced features, JavaScript libraries, and
22
- detailed examples, see references/REFERENCE.md. If you need to fill out a
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
- ## Quick Start
21
+ ## MANDATORY: Professional Style System
26
22
 
27
- ```python
28
- from pypdf import PdfReader, PdfWriter
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
- # Read a PDF
31
- reader = PdfReader("document.pdf")
32
- print(f"Pages: {len(reader.pages)}")
26
+ ### Color Palette
33
27
 
34
- # Extract text
35
- text = ""
36
- for page in reader.pages:
37
- text += page.extract_text()
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
- ## Python Libraries
41
-
42
- ### pypdf — Basic Operations
46
+ ### Typography
43
47
 
44
- #### Merge PDFs
45
48
  ```python
46
- from pypdf import PdfWriter, PdfReader
47
-
48
- writer = PdfWriter()
49
- for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
50
- reader = PdfReader(pdf_file)
51
- for page in reader.pages:
52
- writer.add_page(page)
53
-
54
- with open("merged.pdf", "wb") as output:
55
- writer.write(output)
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
- #### Split PDF
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
- reader = PdfReader("document.pdf")
71
- meta = reader.metadata
72
- print(f"Title: {meta.title}")
73
- print(f"Author: {meta.author}")
74
- print(f"Subject: {meta.subject}")
75
- print(f"Creator: {meta.creator}")
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
- #### Rotate Pages
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
- with open("rotated.pdf", "wb") as output:
88
- writer.write(output)
89
- ```
127
+ Every professional PDF MUST follow this structure:
90
128
 
91
- ### pdfplumber Text and Table Extraction
129
+ ### 1. Cover Page
92
130
 
93
- #### Extract Text with Layout
94
131
  ```python
95
- import pdfplumber
96
-
97
- with pdfplumber.open("document.pdf") as pdf:
98
- for page in pdf.pages:
99
- text = page.extract_text()
100
- print(text)
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
- #### Extract Tables
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
- import pandas as pd
117
-
118
- with pdfplumber.open("document.pdf") as pdf:
119
- all_tables = []
120
- for page in pdf.pages:
121
- tables = page.extract_tables()
122
- for table in tables:
123
- if table:
124
- df = pd.DataFrame(table[1:], columns=table[0])
125
- all_tables.append(df)
126
-
127
- if all_tables:
128
- combined_df = pd.concat(all_tables, ignore_index=True)
129
- combined_df.to_excel("extracted_tables.xlsx", index=False)
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
- ### reportlab Create PDFs
203
+ ### 3. Table of Contents
133
204
 
134
- #### Basic PDF Creation
135
205
  ```python
136
- from reportlab.lib.pagesizes import A4
137
- from reportlab.pdfgen import canvas
138
-
139
- c = canvas.Canvas("hello.pdf", pagesize=A4)
140
- width, height = A4
141
-
142
- # Add text
143
- c.drawString(100, height - 100, "Hello World!")
144
- c.drawString(100, height - 120, "This is a PDF created with reportlab")
145
-
146
- # Add a line
147
- c.line(100, height - 140, 400, height - 140)
148
-
149
- c.save()
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
- #### Create PDF with Multiple Pages
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
- # Page 2
172
- story.append(Paragraph("Page 2", styles['Heading1']))
173
- story.append(Paragraph("Content for page 2", styles['Normal']))
174
-
175
- # Build PDF
176
- doc.build(story)
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
- #### Subscripts and Superscripts
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
- pdftk file1.pdf file2.pdf cat output merged.pdf
186
-
187
- # Split
188
- pdftk input.pdf burst
189
- from reportlab.lib.styles import getSampleStyleSheet
190
-
191
- styles = getSampleStyleSheet()
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
- # Subscripts: use <sub> tag
194
- chemical = Paragraph("H<sub>2</sub>O", styles['Normal'])
307
+ ### 6. Professional Tables
195
308
 
196
- # Superscripts: use <super> tag
197
- squared = Paragraph("x<super>2</super> + y<super>2</super>", styles['Normal'])
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
- For canvas-drawn text (not Paragraph objects), manually adjust font size and
201
- position rather than using Unicode subscripts/superscripts.
202
-
203
- ## Command-Line Tools
351
+ ### 7. Building the Document
204
352
 
205
- ### pdftotext (poppler-utils)
206
- ```bash
207
- # Extract text
208
- pdftotext input.pdf output.txt
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
- # Extract text preserving layout
211
- pdftotext -layout input.pdf output.txt
367
+ story = []
212
368
 
213
- # Extract specific pages
214
- pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5
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
- ### qpdf
218
- ```bash
219
- # Merge PDFs
220
- qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
372
+ build_toc(story, [
373
+ {'title': 'Introduction', 'level': 1, 'page': 3},
374
+ {'title': 'Analysis', 'level': 1, 'page': 5},
375
+ ])
221
376
 
222
- # Split pages
223
- qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
224
- qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
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
- # Rotate pages
227
- qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
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
- # Remove password
230
- qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf
387
+ doc.build(story, onFirstPage=lambda c, d: None,
388
+ onLaterPages=header_footer)
231
389
  ```
232
390
 
233
- ### pdftk (if available)
234
- ```bash
235
- # Merge
236
- pdftk file1.pdf file2.pdf cat output merged.pdf
237
-
238
- # Split
239
- pdftk input.pdf burst
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
- # Rotate
242
- pdftk input.pdf rotate 1east output rotated.pdf
414
+ with pdfplumber.open("document.pdf") as pdf:
415
+ for page in pdf.pages:
416
+ print(page.extract_text())
243
417
  ```
244
418
 
245
- ## Common Tasks
246
-
247
- ### Extract Text from Scanned PDFs
419
+ ### Extract Tables to DataFrame
248
420
  ```python
249
- # Requires: pip install pytesseract pdf2image
250
- import pytesseract
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
- # OCR each page
257
- text = ""
258
- for i, image in enumerate(images):
259
- text += f"Page {i+1}:\n"
260
- text += pytesseract.image_to_string(image, lang="por+eng")
261
- text += "\n\n"
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
- print(text)
432
+ if tables:
433
+ combined = pd.concat(tables, ignore_index=True)
434
+ combined.to_excel("extracted.xlsx", index=False)
264
435
  ```
265
436
 
266
- ### Add Watermark
267
- ```python
268
- from pypdf import PdfReader, PdfWriter
437
+ ## Merge, Split, Protect
269
438
 
270
- # Load watermark
271
- watermark = PdfReader("watermark.pdf").pages[0]
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
- for page in reader.pages:
278
- page.merge_page(watermark)
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
- # Add password
303
- writer.encrypt("userpassword", "ownerpassword")
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
- with open("encrypted.pdf", "wb") as output:
306
- writer.write(output)
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 | Best Tool | Command/Code |
312
- |-----------------------|--------------|-------------------------------------|
313
- | Merge PDFs | pypdf | `writer.add_page(page)` |
314
- | Split PDFs | pypdf | One page per file |
315
- | Extract text | pdfplumber | `page.extract_text()` |
316
- | Extract tables | pdfplumber | `page.extract_tables()` |
317
- | Create PDFs | reportlab | Canvas or Platypus |
318
- | Command line merge | qpdf | `qpdf --empty --pages ...` |
319
- | OCR scanned PDFs | pytesseract | Convert to image first |
320
- | Fill PDF forms | pypdf / pdf-lib (see FORMS.md) | See references/FORMS.md |
321
-
322
- ## Next Steps
323
-
324
- - For advanced pypdfium2 usage, see references/REFERENCE.md
325
- - For JavaScript libraries (pdf-lib), see references/REFERENCE.md
326
- - If you need to fill out a PDF form, follow the instructions in references/FORMS.md
327
- - For troubleshooting guides, see references/REFERENCE.md
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
- Create a simple PDF report from CSV data using reportlab.
3
+ Professional PDF Report Generator BluMa Template
3
4
 
4
5
  Usage:
5
- python create_report.py --input data.csv --output ./artifacts/report.pdf
6
- python create_report.py --input data.csv --output report.pdf --title "Sales Report Q1 2026"
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 csv
10
- import sys
11
-
12
- def create_report(input_csv: str, output_pdf: str, title: str = "Report") -> None:
13
- try:
14
- from reportlab.lib.pagesizes import A4
15
- from reportlab.lib import colors
16
- from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
17
- from reportlab.lib.styles import getSampleStyleSheet
18
- except ImportError:
19
- print("Error: reportlab is required. Install with: pip install reportlab", file=sys.stderr)
20
- sys.exit(1)
21
-
22
- with open(input_csv, "r", encoding="utf-8") as f:
23
- reader = csv.reader(f)
24
- data = list(reader)
25
-
26
- if not data:
27
- print("Error: CSV file is empty", file=sys.stderr)
28
- sys.exit(1)
29
-
30
- doc = SimpleDocTemplate(output_pdf, pagesize=A4)
31
- styles = getSampleStyleSheet()
32
- elements = []
33
-
34
- elements.append(Paragraph(title, styles["Title"]))
35
- elements.append(Spacer(1, 20))
36
-
37
- table = Table(data)
38
- table.setStyle(TableStyle([
39
- ("BACKGROUND", (0, 0), (-1, 0), colors.grey),
40
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
41
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
42
- ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
43
- ("FONTSIZE", (0, 0), (-1, 0), 12),
44
- ("BOTTOMPADDING", (0, 0), (-1, 0), 12),
45
- ("BACKGROUND", (0, 1), (-1, -1), colors.beige),
46
- ("GRID", (0, 0), (-1, -1), 1, colors.black),
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 '&nbsp;&nbsp;&nbsp;&nbsp;'
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
- elements.append(table)
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
- if __name__ == "__main__":
54
- parser = argparse.ArgumentParser(description="Create PDF report from CSV")
55
- parser.add_argument("--input", "-i", required=True, help="Input CSV file")
56
- parser.add_argument("--output", "-o", required=True, help="Output PDF path")
57
- parser.add_argument("--title", "-t", default="Report", help="Report title")
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
- create_report(args.input, args.output, args.title)
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomad-e/bluma-cli",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "BluMa independent agent for automation and advanced software engineering.",
5
5
  "author": "Alex Fonseca",
6
6
  "license": "Apache-2.0",