@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.
Files changed (39) hide show
  1. package/dist/config/skills/git-commit/LICENSE.txt +18 -0
  2. package/dist/config/skills/git-commit/SKILL.md +258 -0
  3. package/dist/config/skills/git-commit/references/REFERENCE.md +249 -0
  4. package/dist/config/skills/git-commit/scripts/validate_commit_msg.py +163 -0
  5. package/dist/config/skills/git-pr/LICENSE.txt +18 -0
  6. package/dist/config/skills/git-pr/SKILL.md +293 -0
  7. package/dist/config/skills/git-pr/references/REFERENCE.md +256 -0
  8. package/dist/config/skills/git-pr/scripts/validate_commits.py +112 -0
  9. package/dist/config/skills/pdf/LICENSE.txt +26 -0
  10. package/dist/config/skills/pdf/SKILL.md +327 -0
  11. package/dist/config/skills/pdf/references/FORMS.md +69 -0
  12. package/dist/config/skills/pdf/references/REFERENCE.md +52 -0
  13. package/dist/config/skills/pdf/scripts/create_report.py +59 -0
  14. package/dist/config/skills/pdf/scripts/merge_pdfs.py +39 -0
  15. package/dist/config/skills/skill-creator/LICENSE.txt +26 -0
  16. package/dist/config/skills/skill-creator/SKILL.md +229 -0
  17. package/dist/config/skills/xlsx/LICENSE.txt +18 -0
  18. package/dist/config/skills/xlsx/SKILL.md +298 -0
  19. package/dist/config/skills/xlsx/references/REFERENCE.md +337 -0
  20. package/dist/config/skills/xlsx/scripts/office/__init__.py +2 -0
  21. package/dist/config/skills/xlsx/scripts/office/__pycache__/__init__.cpython-312.pyc +0 -0
  22. package/dist/config/skills/xlsx/scripts/office/__pycache__/pack.cpython-312.pyc +0 -0
  23. package/dist/config/skills/xlsx/scripts/office/__pycache__/soffice.cpython-312.pyc +0 -0
  24. package/dist/config/skills/xlsx/scripts/office/__pycache__/unpack.cpython-312.pyc +0 -0
  25. package/dist/config/skills/xlsx/scripts/office/__pycache__/validate.cpython-312.pyc +0 -0
  26. package/dist/config/skills/xlsx/scripts/office/pack.py +58 -0
  27. package/dist/config/skills/xlsx/scripts/office/soffice.py +180 -0
  28. package/dist/config/skills/xlsx/scripts/office/unpack.py +63 -0
  29. package/dist/config/skills/xlsx/scripts/office/validate.py +122 -0
  30. package/dist/config/skills/xlsx/scripts/recalc.py +143 -0
  31. package/dist/main.js +201 -50
  32. package/package.json +1 -1
  33. package/dist/config/example.bluma-mcp.json.txt +0 -14
  34. package/dist/config/models_config.json +0 -78
  35. package/dist/skills/git-conventional/LICENSE.txt +0 -3
  36. package/dist/skills/git-conventional/SKILL.md +0 -83
  37. package/dist/skills/skill-creator/SKILL.md +0 -495
  38. package/dist/skills/testing/LICENSE.txt +0 -3
  39. 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
+ ```
@@ -0,0 +1,2 @@
1
+ # office — Shared utilities for LibreOffice-based document processing.
2
+ # Used internally by xlsx, docx, and pptx skills.
@@ -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
+ ]