@nomad-e/bluma-cli 0.1.18 → 0.1.20
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.
- package/dist/config/skills/git-commit/LICENSE.txt +18 -0
- package/dist/config/skills/git-commit/SKILL.md +258 -0
- package/dist/config/skills/git-commit/references/REFERENCE.md +249 -0
- package/dist/config/skills/git-commit/scripts/validate_commit_msg.py +163 -0
- package/dist/config/skills/git-pr/LICENSE.txt +18 -0
- package/dist/config/skills/git-pr/SKILL.md +293 -0
- package/dist/config/skills/git-pr/references/REFERENCE.md +256 -0
- package/dist/config/skills/git-pr/scripts/validate_commits.py +112 -0
- package/dist/config/skills/pdf/LICENSE.txt +26 -0
- package/dist/config/skills/pdf/SKILL.md +327 -0
- package/dist/config/skills/pdf/references/FORMS.md +69 -0
- package/dist/config/skills/pdf/references/REFERENCE.md +52 -0
- package/dist/config/skills/pdf/scripts/create_report.py +59 -0
- package/dist/config/skills/pdf/scripts/merge_pdfs.py +39 -0
- package/dist/config/skills/skill-creator/LICENSE.txt +26 -0
- package/dist/config/skills/skill-creator/SKILL.md +229 -0
- package/dist/config/skills/xlsx/LICENSE.txt +18 -0
- package/dist/config/skills/xlsx/SKILL.md +298 -0
- package/dist/config/skills/xlsx/references/REFERENCE.md +337 -0
- package/dist/config/skills/xlsx/scripts/office/__init__.py +2 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/__init__.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/pack.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/soffice.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/unpack.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/__pycache__/validate.cpython-312.pyc +0 -0
- package/dist/config/skills/xlsx/scripts/office/pack.py +58 -0
- package/dist/config/skills/xlsx/scripts/office/soffice.py +180 -0
- package/dist/config/skills/xlsx/scripts/office/unpack.py +63 -0
- package/dist/config/skills/xlsx/scripts/office/validate.py +122 -0
- package/dist/config/skills/xlsx/scripts/recalc.py +143 -0
- package/dist/main.js +201 -50
- package/package.json +1 -1
- package/dist/config/example.bluma-mcp.json.txt +0 -14
- package/dist/config/models_config.json +0 -78
- package/dist/skills/git-conventional/LICENSE.txt +0 -3
- package/dist/skills/git-conventional/SKILL.md +0 -83
- package/dist/skills/skill-creator/SKILL.md +0 -495
- package/dist/skills/testing/LICENSE.txt +0 -3
- package/dist/skills/testing/SKILL.md +0 -114
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# XLSX — Advanced Patterns Reference
|
|
2
|
+
|
|
3
|
+
## Chart Creation with openpyxl
|
|
4
|
+
|
|
5
|
+
### Bar Chart
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from openpyxl.chart import BarChart, Reference
|
|
9
|
+
|
|
10
|
+
chart = BarChart()
|
|
11
|
+
chart.type = "col"
|
|
12
|
+
chart.title = "Revenue by Quarter"
|
|
13
|
+
chart.y_axis.title = "Revenue ($mm)"
|
|
14
|
+
chart.x_axis.title = "Quarter"
|
|
15
|
+
chart.style = 10
|
|
16
|
+
|
|
17
|
+
data = Reference(ws, min_col=2, min_row=1, max_col=5, max_row=10)
|
|
18
|
+
cats = Reference(ws, min_col=1, min_row=2, max_row=10)
|
|
19
|
+
chart.add_data(data, titles_from_data=True)
|
|
20
|
+
chart.set_categories(cats)
|
|
21
|
+
chart.shape = 4
|
|
22
|
+
|
|
23
|
+
ws.add_chart(chart, "G2")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Line Chart (Time Series)
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from openpyxl.chart import LineChart, Reference
|
|
30
|
+
|
|
31
|
+
chart = LineChart()
|
|
32
|
+
chart.title = "Revenue Trend"
|
|
33
|
+
chart.y_axis.title = "Revenue ($mm)"
|
|
34
|
+
chart.x_axis.title = "Year"
|
|
35
|
+
chart.style = 13
|
|
36
|
+
chart.width = 20
|
|
37
|
+
chart.height = 12
|
|
38
|
+
|
|
39
|
+
data = Reference(ws, min_col=2, min_row=1, max_col=2, max_row=10)
|
|
40
|
+
cats = Reference(ws, min_col=1, min_row=2, max_row=10)
|
|
41
|
+
chart.add_data(data, titles_from_data=True)
|
|
42
|
+
chart.set_categories(cats)
|
|
43
|
+
|
|
44
|
+
series = chart.series[0]
|
|
45
|
+
series.graphicalProperties.line.width = 25000
|
|
46
|
+
|
|
47
|
+
ws.add_chart(chart, "D2")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Pie Chart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from openpyxl.chart import PieChart, Reference
|
|
54
|
+
|
|
55
|
+
chart = PieChart()
|
|
56
|
+
chart.title = "Market Share"
|
|
57
|
+
chart.style = 26
|
|
58
|
+
|
|
59
|
+
data = Reference(ws, min_col=2, min_row=1, max_row=6)
|
|
60
|
+
cats = Reference(ws, min_col=1, min_row=2, max_row=6)
|
|
61
|
+
chart.add_data(data, titles_from_data=True)
|
|
62
|
+
chart.set_categories(cats)
|
|
63
|
+
|
|
64
|
+
ws.add_chart(chart, "D2")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Conditional Formatting
|
|
68
|
+
|
|
69
|
+
### Color Scale (Heatmap)
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from openpyxl.formatting.rule import ColorScaleRule
|
|
73
|
+
|
|
74
|
+
ws.conditional_formatting.add(
|
|
75
|
+
'B2:B100',
|
|
76
|
+
ColorScaleRule(
|
|
77
|
+
start_type='min', start_color='F8696B',
|
|
78
|
+
mid_type='percentile', mid_value=50, mid_color='FFEB84',
|
|
79
|
+
end_type='max', end_color='63BE7B'
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Data Bars
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from openpyxl.formatting.rule import DataBarRule
|
|
88
|
+
|
|
89
|
+
ws.conditional_formatting.add(
|
|
90
|
+
'C2:C100',
|
|
91
|
+
DataBarRule(
|
|
92
|
+
start_type='min', end_type='max',
|
|
93
|
+
color='5B9BD5', showValue=True
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Highlight Cells by Condition
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from openpyxl.formatting.rule import CellIsRule
|
|
102
|
+
from openpyxl.styles import PatternFill
|
|
103
|
+
|
|
104
|
+
red_fill = PatternFill('solid', fgColor='FFC7CE')
|
|
105
|
+
green_fill = PatternFill('solid', fgColor='C6EFCE')
|
|
106
|
+
|
|
107
|
+
ws.conditional_formatting.add(
|
|
108
|
+
'D2:D100',
|
|
109
|
+
CellIsRule(operator='lessThan', formula=['0'], fill=red_fill)
|
|
110
|
+
)
|
|
111
|
+
ws.conditional_formatting.add(
|
|
112
|
+
'D2:D100',
|
|
113
|
+
CellIsRule(operator='greaterThan', formula=['0'], fill=green_fill)
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Data Validation (Dropdowns)
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from openpyxl.worksheet.datavalidation import DataValidation
|
|
121
|
+
|
|
122
|
+
dv = DataValidation(
|
|
123
|
+
type="list",
|
|
124
|
+
formula1='"Option A,Option B,Option C"',
|
|
125
|
+
allow_blank=True
|
|
126
|
+
)
|
|
127
|
+
dv.error = "Invalid selection"
|
|
128
|
+
dv.errorTitle = "Input Error"
|
|
129
|
+
dv.prompt = "Select from the list"
|
|
130
|
+
dv.promptTitle = "Choose Option"
|
|
131
|
+
|
|
132
|
+
ws.add_data_validation(dv)
|
|
133
|
+
dv.add('E2:E100')
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Numeric Range Validation
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
dv_num = DataValidation(
|
|
140
|
+
type="decimal",
|
|
141
|
+
operator="between",
|
|
142
|
+
formula1=0,
|
|
143
|
+
formula2=1
|
|
144
|
+
)
|
|
145
|
+
dv_num.error = "Value must be between 0% and 100%"
|
|
146
|
+
ws.add_data_validation(dv_num)
|
|
147
|
+
dv_num.add('F2:F100')
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Pivot-Style Summary Tables
|
|
151
|
+
|
|
152
|
+
openpyxl does not have native pivot tables. Build them manually:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
import pandas as pd
|
|
156
|
+
from openpyxl import load_workbook
|
|
157
|
+
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
|
|
158
|
+
|
|
159
|
+
df = pd.read_excel('data.xlsx')
|
|
160
|
+
pivot = df.pivot_table(
|
|
161
|
+
index='Category',
|
|
162
|
+
columns='Year',
|
|
163
|
+
values='Revenue',
|
|
164
|
+
aggfunc='sum',
|
|
165
|
+
margins=True,
|
|
166
|
+
margins_name='Total'
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
wb = load_workbook('data.xlsx')
|
|
170
|
+
ws = wb.create_sheet('Pivot')
|
|
171
|
+
|
|
172
|
+
header_font = Font(bold=True, name='Arial', size=11)
|
|
173
|
+
header_fill = PatternFill('solid', fgColor='4472C4')
|
|
174
|
+
header_font_white = Font(bold=True, name='Arial', size=11, color='FFFFFF')
|
|
175
|
+
border = Border(
|
|
176
|
+
bottom=Side(style='thin', color='D9D9D9')
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
ws.cell(row=1, column=1, value='Category').font = header_font_white
|
|
180
|
+
ws['A1'].fill = header_fill
|
|
181
|
+
for j, col_name in enumerate(pivot.columns, 2):
|
|
182
|
+
cell = ws.cell(row=1, column=j, value=str(col_name))
|
|
183
|
+
cell.font = header_font_white
|
|
184
|
+
cell.fill = header_fill
|
|
185
|
+
cell.alignment = Alignment(horizontal='center')
|
|
186
|
+
|
|
187
|
+
for i, (idx, row_data) in enumerate(pivot.iterrows(), 2):
|
|
188
|
+
ws.cell(row=i, column=1, value=idx).font = Font(
|
|
189
|
+
bold=(idx == 'Total'), name='Arial', size=11
|
|
190
|
+
)
|
|
191
|
+
for j, val in enumerate(row_data, 2):
|
|
192
|
+
cell = ws.cell(row=i, column=j, value=val)
|
|
193
|
+
cell.number_format = '$#,##0;($#,##0);"-"'
|
|
194
|
+
cell.border = border
|
|
195
|
+
if idx == 'Total':
|
|
196
|
+
cell.font = Font(bold=True, name='Arial', size=11)
|
|
197
|
+
|
|
198
|
+
wb.save('data.xlsx')
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Print Layout Configuration
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
ws.page_setup.orientation = 'landscape'
|
|
205
|
+
ws.page_setup.paperSize = ws.PAPERSIZE_A4
|
|
206
|
+
ws.page_setup.fitToWidth = 1
|
|
207
|
+
ws.page_setup.fitToHeight = 0
|
|
208
|
+
|
|
209
|
+
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
|
210
|
+
|
|
211
|
+
ws.oddHeader.center.text = "Company Name — Confidential"
|
|
212
|
+
ws.oddFooter.left.text = "Page &P of &N"
|
|
213
|
+
ws.oddFooter.right.text = "&D"
|
|
214
|
+
|
|
215
|
+
ws.print_title_rows = '1:2'
|
|
216
|
+
ws.print_area = 'A1:H50'
|
|
217
|
+
|
|
218
|
+
ws.page_margins = openpyxl.worksheet.page.PageMargins(
|
|
219
|
+
left=0.5, right=0.5, top=0.75, bottom=0.75,
|
|
220
|
+
header=0.3, footer=0.3
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Freeze Panes and Auto-Filter
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
ws.freeze_panes = 'B2'
|
|
228
|
+
|
|
229
|
+
ws.auto_filter.ref = f'A1:{get_column_letter(ws.max_column)}{ws.max_row}'
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Named Ranges
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from openpyxl.workbook.defined_name import DefinedName
|
|
236
|
+
|
|
237
|
+
ref = f"'{ws.title}'!$B$3"
|
|
238
|
+
defn = DefinedName('GrowthRate', attr_text=ref)
|
|
239
|
+
wb.defined_names.add(defn)
|
|
240
|
+
|
|
241
|
+
ws['C5'] = '=C4*(1+GrowthRate)'
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Merging Cells and Grouped Headers
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
ws.merge_cells('A1:D1')
|
|
248
|
+
ws['A1'] = 'Quarterly Revenue Summary'
|
|
249
|
+
ws['A1'].font = Font(bold=True, size=14, name='Arial')
|
|
250
|
+
ws['A1'].alignment = Alignment(horizontal='center')
|
|
251
|
+
|
|
252
|
+
ws.merge_cells('B2:C2')
|
|
253
|
+
ws['B2'] = 'Q1-Q2'
|
|
254
|
+
ws.merge_cells('D2:E2')
|
|
255
|
+
ws['D2'] = 'Q3-Q4'
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Working with Large Datasets
|
|
259
|
+
|
|
260
|
+
### Write-Only Mode (memory efficient)
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
wb = Workbook(write_only=True)
|
|
264
|
+
ws = wb.create_sheet()
|
|
265
|
+
|
|
266
|
+
for row_data in generate_rows():
|
|
267
|
+
ws.append(row_data)
|
|
268
|
+
|
|
269
|
+
wb.save('large_output.xlsx')
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Read-Only Mode
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
wb = load_workbook('large_file.xlsx', read_only=True)
|
|
276
|
+
ws = wb.active
|
|
277
|
+
|
|
278
|
+
for row in ws.iter_rows(min_row=2, values_only=True):
|
|
279
|
+
process(row)
|
|
280
|
+
|
|
281
|
+
wb.close()
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Financial Model Templates
|
|
285
|
+
|
|
286
|
+
### DCF Valuation Layout
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
Sheet: Assumptions
|
|
290
|
+
B3: Revenue Growth Rate (blue, input)
|
|
291
|
+
B4: EBITDA Margin (blue, input)
|
|
292
|
+
B5: Tax Rate (blue, input)
|
|
293
|
+
B6: WACC (blue, input)
|
|
294
|
+
B7: Terminal Growth (blue, input)
|
|
295
|
+
|
|
296
|
+
Sheet: Projections
|
|
297
|
+
Row 1: Headers (Year 1, Year 2, ... Year 5)
|
|
298
|
+
Row 3: Revenue = =Prior*(1+Assumptions!$B$3)
|
|
299
|
+
Row 4: EBITDA = =B3*Assumptions!$B$4
|
|
300
|
+
Row 5: D&A (blue, input)
|
|
301
|
+
Row 6: EBIT = =B4-B5
|
|
302
|
+
Row 7: Tax = =B6*Assumptions!$B$5
|
|
303
|
+
Row 8: NOPAT = =B6-B7
|
|
304
|
+
Row 9: CapEx (blue, input)
|
|
305
|
+
Row 10: Change in WC (blue, input)
|
|
306
|
+
Row 11: FCF = =B8+B5-B9-B10
|
|
307
|
+
|
|
308
|
+
Sheet: Valuation
|
|
309
|
+
B3: PV of FCFs = =NPV(Assumptions!$B$6, Projections!B11:F11)
|
|
310
|
+
B4: Terminal Value = =Projections!F11*(1+Assumptions!$B$7)/(Assumptions!$B$6-Assumptions!$B$7)
|
|
311
|
+
B5: PV of Terminal = =B4/(1+Assumptions!$B$6)^5
|
|
312
|
+
B6: Enterprise Value = =B3+B5
|
|
313
|
+
B7: Less: Net Debt (blue, input)
|
|
314
|
+
B8: Equity Value = =B6-B7
|
|
315
|
+
B9: Shares Outstanding (blue, input)
|
|
316
|
+
B10: Implied Price = =B8/B9
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Sensitivity Table
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
base_row = 15
|
|
323
|
+
scenarios = [-0.02, -0.01, 0, 0.01, 0.02]
|
|
324
|
+
|
|
325
|
+
for i, delta in enumerate(scenarios):
|
|
326
|
+
col = i + 2
|
|
327
|
+
ws.cell(row=base_row, column=col, value=base_wacc + delta)
|
|
328
|
+
ws.cell(row=base_row, column=col).number_format = '0.0%'
|
|
329
|
+
|
|
330
|
+
for j, tg_delta in enumerate(scenarios):
|
|
331
|
+
row = base_row + 1 + j
|
|
332
|
+
ws.cell(row=row, column=1, value=base_tg + tg_delta)
|
|
333
|
+
ws.cell(row=row, column=1).number_format = '0.0%'
|
|
334
|
+
ws.cell(row=row, column=col).value = (
|
|
335
|
+
f'=Projections!F11*(1+A{row})/({get_column_letter(col)}{base_row}-A{row})'
|
|
336
|
+
)
|
|
337
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pack an unpacked Office Open XML directory back into an .xlsx/.docx/.pptx file.
|
|
3
|
+
|
|
4
|
+
Office files are ZIP archives containing XML. This module compresses a
|
|
5
|
+
directory structure back into a valid Office file, respecting the required
|
|
6
|
+
entry order ([Content_Types].xml first).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CONTENT_TYPES = '[Content_Types].xml'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pack(source_dir: str, output_path: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Compress a directory into an Office Open XML file.
|
|
19
|
+
|
|
20
|
+
The [Content_Types].xml file is written first as required by the
|
|
21
|
+
Office Open XML specification.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
source_dir: Path to the unpacked directory.
|
|
25
|
+
output_path: Destination .xlsx/.docx/.pptx file path.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Absolute path to the created file.
|
|
29
|
+
"""
|
|
30
|
+
abs_source = os.path.abspath(source_dir)
|
|
31
|
+
abs_output = os.path.abspath(output_path)
|
|
32
|
+
|
|
33
|
+
if not os.path.isdir(abs_source):
|
|
34
|
+
raise FileNotFoundError(f'Source directory not found: {abs_source}')
|
|
35
|
+
|
|
36
|
+
entries = []
|
|
37
|
+
ct_path = None
|
|
38
|
+
|
|
39
|
+
for root, _, files in os.walk(abs_source):
|
|
40
|
+
for fname in files:
|
|
41
|
+
full = os.path.join(root, fname)
|
|
42
|
+
arcname = os.path.relpath(full, abs_source)
|
|
43
|
+
|
|
44
|
+
if arcname == CONTENT_TYPES:
|
|
45
|
+
ct_path = full
|
|
46
|
+
else:
|
|
47
|
+
entries.append((full, arcname))
|
|
48
|
+
|
|
49
|
+
entries.sort(key=lambda x: x[1])
|
|
50
|
+
|
|
51
|
+
with zipfile.ZipFile(abs_output, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
52
|
+
if ct_path:
|
|
53
|
+
zf.write(ct_path, CONTENT_TYPES)
|
|
54
|
+
|
|
55
|
+
for full_path, arcname in entries:
|
|
56
|
+
zf.write(full_path, arcname)
|
|
57
|
+
|
|
58
|
+
return abs_output
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LibreOffice process management for sandboxed and standard environments.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Finding the soffice binary across platforms
|
|
6
|
+
- Running recalculation macros in headless mode
|
|
7
|
+
- Configuring Unix sockets for restricted environments
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SOFFICE_SEARCH_PATHS = [
|
|
18
|
+
'/usr/bin/soffice',
|
|
19
|
+
'/usr/bin/libreoffice',
|
|
20
|
+
'/usr/lib/libreoffice/program/soffice',
|
|
21
|
+
'/snap/bin/libreoffice',
|
|
22
|
+
'/opt/libreoffice/program/soffice',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
MACOS_PATHS = [
|
|
26
|
+
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
|
|
27
|
+
os.path.expanduser('~/Applications/LibreOffice.app/Contents/MacOS/soffice'),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_soffice() -> str | None:
|
|
32
|
+
"""Find the soffice binary on this system."""
|
|
33
|
+
found = shutil.which('soffice') or shutil.which('libreoffice')
|
|
34
|
+
if found:
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
search = MACOS_PATHS if platform.system() == 'Darwin' else SOFFICE_SEARCH_PATHS
|
|
38
|
+
|
|
39
|
+
for path in search:
|
|
40
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _prepare_user_profile(base_dir: str) -> str:
|
|
47
|
+
"""Create a temporary user profile to avoid lock conflicts."""
|
|
48
|
+
profile_dir = os.path.join(base_dir, 'libreoffice-profile')
|
|
49
|
+
os.makedirs(profile_dir, exist_ok=True)
|
|
50
|
+
return profile_dir
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_macro_content(filepath: str) -> str:
|
|
54
|
+
"""Build a LibreOffice Basic macro that recalculates all sheets."""
|
|
55
|
+
abs_path = os.path.abspath(filepath)
|
|
56
|
+
url = 'file://' + abs_path.replace(' ', '%20')
|
|
57
|
+
|
|
58
|
+
return f"""
|
|
59
|
+
Sub RecalcAndSave
|
|
60
|
+
Dim oDoc As Object
|
|
61
|
+
Dim oSheets As Object
|
|
62
|
+
Dim oSheet As Object
|
|
63
|
+
Dim i As Integer
|
|
64
|
+
|
|
65
|
+
oDoc = StarDesktop.loadComponentFromURL( _
|
|
66
|
+
"{url}", "_blank", 0, _
|
|
67
|
+
Array(MakePropertyValue("Hidden", True), _
|
|
68
|
+
MakePropertyValue("MacroExecutionMode", 4)))
|
|
69
|
+
|
|
70
|
+
If IsNull(oDoc) Or IsEmpty(oDoc) Then
|
|
71
|
+
MsgBox "Failed to open document"
|
|
72
|
+
Exit Sub
|
|
73
|
+
End If
|
|
74
|
+
|
|
75
|
+
oSheets = oDoc.getSheets()
|
|
76
|
+
For i = 0 To oSheets.getCount() - 1
|
|
77
|
+
oSheet = oSheets.getByIndex(i)
|
|
78
|
+
Next i
|
|
79
|
+
|
|
80
|
+
oDoc.calculateAll()
|
|
81
|
+
oDoc.calculateAll()
|
|
82
|
+
|
|
83
|
+
oDoc.store()
|
|
84
|
+
oDoc.close(True)
|
|
85
|
+
End Sub
|
|
86
|
+
|
|
87
|
+
Function MakePropertyValue(sName As String, vValue As Variant) As com.sun.star.beans.PropertyValue
|
|
88
|
+
Dim oProperty As New com.sun.star.beans.PropertyValue
|
|
89
|
+
oProperty.Name = sName
|
|
90
|
+
oProperty.Value = vValue
|
|
91
|
+
MakePropertyValue = oProperty
|
|
92
|
+
End Function
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_soffice_recalc(
|
|
97
|
+
soffice_path: str,
|
|
98
|
+
filepath: str,
|
|
99
|
+
timeout: int = 60,
|
|
100
|
+
) -> tuple[bool, str]:
|
|
101
|
+
"""
|
|
102
|
+
Recalculate an Excel file using LibreOffice headless mode.
|
|
103
|
+
|
|
104
|
+
Returns (success: bool, message: str).
|
|
105
|
+
"""
|
|
106
|
+
abs_path = os.path.abspath(filepath)
|
|
107
|
+
|
|
108
|
+
if not os.path.isfile(abs_path):
|
|
109
|
+
return False, f'File not found: {abs_path}'
|
|
110
|
+
|
|
111
|
+
with tempfile.TemporaryDirectory(prefix='bluma-lo-') as tmp_dir:
|
|
112
|
+
profile_dir = _prepare_user_profile(tmp_dir)
|
|
113
|
+
|
|
114
|
+
macro_content = _build_macro_content(abs_path)
|
|
115
|
+
macro_dir = os.path.join(
|
|
116
|
+
profile_dir, 'user', 'basic', 'Standard'
|
|
117
|
+
)
|
|
118
|
+
os.makedirs(macro_dir, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
macro_file = os.path.join(macro_dir, 'BluMaRecalc.xba')
|
|
121
|
+
with open(macro_file, 'w') as f:
|
|
122
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
123
|
+
f.write('<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">\n')
|
|
124
|
+
f.write('<script:module xmlns:script="http://openoffice.org/2000/script" ')
|
|
125
|
+
f.write('script:name="BluMaRecalc" script:language="StarBasic">\n')
|
|
126
|
+
f.write(macro_content)
|
|
127
|
+
f.write('\n</script:module>\n')
|
|
128
|
+
|
|
129
|
+
dialog_lc = os.path.join(macro_dir, 'dialog.xlc')
|
|
130
|
+
with open(dialog_lc, 'w') as f:
|
|
131
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
132
|
+
f.write('<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">\n')
|
|
133
|
+
f.write('<library:library xmlns:library="http://openoffice.org/2000/library" ')
|
|
134
|
+
f.write('library:name="Standard" library:readonly="false" library:passwordprotected="false">\n')
|
|
135
|
+
f.write('</library:library>\n')
|
|
136
|
+
|
|
137
|
+
script_lc = os.path.join(macro_dir, 'script.xlc')
|
|
138
|
+
with open(script_lc, 'w') as f:
|
|
139
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
140
|
+
f.write('<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">\n')
|
|
141
|
+
f.write('<library:library xmlns:library="http://openoffice.org/2000/library" ')
|
|
142
|
+
f.write('library:name="Standard" library:readonly="false" library:passwordprotected="false">\n')
|
|
143
|
+
f.write(f' <library:element library:name="BluMaRecalc"/>\n')
|
|
144
|
+
f.write('</library:library>\n')
|
|
145
|
+
|
|
146
|
+
env = os.environ.copy()
|
|
147
|
+
env['HOME'] = tmp_dir
|
|
148
|
+
env['SAL_USE_VCLPLUGIN'] = 'gen'
|
|
149
|
+
|
|
150
|
+
cmd = [
|
|
151
|
+
soffice_path,
|
|
152
|
+
'--headless',
|
|
153
|
+
'--invisible',
|
|
154
|
+
'--nologo',
|
|
155
|
+
'--norestore',
|
|
156
|
+
f'-env:UserInstallation=file://{profile_dir}',
|
|
157
|
+
f'macro:///Standard.BluMaRecalc.RecalcAndSave',
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
proc = subprocess.run(
|
|
162
|
+
cmd,
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
env=env,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if proc.returncode != 0:
|
|
170
|
+
stderr = proc.stderr.strip()[:500] if proc.stderr else 'No error output'
|
|
171
|
+
return False, f'LibreOffice exited with code {proc.returncode}: {stderr}'
|
|
172
|
+
|
|
173
|
+
return True, 'Recalculation completed'
|
|
174
|
+
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
return False, f'LibreOffice timed out after {timeout} seconds'
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
return False, f'soffice binary not found at: {soffice_path}'
|
|
179
|
+
except Exception as e:
|
|
180
|
+
return False, f'Unexpected error: {str(e)}'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unpack an Office Open XML file (.xlsx/.docx/.pptx) into a directory.
|
|
3
|
+
|
|
4
|
+
This allows direct manipulation of the internal XML structure, which is
|
|
5
|
+
useful for repairs, validation, or modifications not possible through
|
|
6
|
+
standard libraries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def unpack(filepath: str, dest_dir: str | None = None) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Extract an Office file into a directory.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
filepath: Path to the .xlsx/.docx/.pptx file.
|
|
19
|
+
dest_dir: Destination directory. If None, creates one next to
|
|
20
|
+
the source file with '_unpacked' suffix.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Absolute path to the unpacked directory.
|
|
24
|
+
"""
|
|
25
|
+
abs_path = os.path.abspath(filepath)
|
|
26
|
+
|
|
27
|
+
if not os.path.isfile(abs_path):
|
|
28
|
+
raise FileNotFoundError(f'File not found: {abs_path}')
|
|
29
|
+
|
|
30
|
+
if dest_dir is None:
|
|
31
|
+
base = os.path.splitext(abs_path)[0]
|
|
32
|
+
dest_dir = base + '_unpacked'
|
|
33
|
+
|
|
34
|
+
abs_dest = os.path.abspath(dest_dir)
|
|
35
|
+
os.makedirs(abs_dest, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with zipfile.ZipFile(abs_path, 'r') as zf:
|
|
39
|
+
zf.extractall(abs_dest)
|
|
40
|
+
except zipfile.BadZipFile:
|
|
41
|
+
raise ValueError(f'Not a valid Office/ZIP file: {abs_path}')
|
|
42
|
+
|
|
43
|
+
return abs_dest
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_contents(filepath: str) -> list[dict]:
|
|
47
|
+
"""
|
|
48
|
+
List the internal structure of an Office file.
|
|
49
|
+
|
|
50
|
+
Returns a list of dicts with 'name', 'size', and 'compressed_size'.
|
|
51
|
+
"""
|
|
52
|
+
abs_path = os.path.abspath(filepath)
|
|
53
|
+
|
|
54
|
+
with zipfile.ZipFile(abs_path, 'r') as zf:
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
'name': info.filename,
|
|
58
|
+
'size': info.file_size,
|
|
59
|
+
'compressed_size': info.compress_size,
|
|
60
|
+
}
|
|
61
|
+
for info in zf.infolist()
|
|
62
|
+
if not info.is_dir()
|
|
63
|
+
]
|