This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recalculate one trusted local XLSX through a dedicated LibreOffice UNO pipe.
|
||||
|
||||
The helper accepts exactly one JSON line on stdin and writes exactly one JSON
|
||||
line on stdout. It never emits user-provided cell values or formulas.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import uno
|
||||
|
||||
|
||||
CANARY_PREFIX = "_MAESTRO_RECALC_"
|
||||
CONNECT_ATTEMPTS = 300
|
||||
CONNECT_DELAY_SECONDS = 0.1
|
||||
|
||||
|
||||
class RecalculationError(Exception):
|
||||
"""Known failure with a stable, non-sensitive machine code."""
|
||||
|
||||
|
||||
def property_value(name, value):
|
||||
prop = uno.createUnoStruct("com.sun.star.beans.PropertyValue")
|
||||
prop.Name = name
|
||||
prop.Value = value
|
||||
return prop
|
||||
|
||||
|
||||
def connect(pipe_name):
|
||||
local_context = uno.getComponentContext()
|
||||
resolver = local_context.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.bridge.UnoUrlResolver", local_context
|
||||
)
|
||||
url = f"uno:pipe,name={pipe_name};urp;StarOffice.ComponentContext"
|
||||
last_error = None
|
||||
for _ in range(CONNECT_ATTEMPTS):
|
||||
try:
|
||||
return resolver.resolve(url)
|
||||
except Exception as error: # UNO raises several bridge-specific types.
|
||||
last_error = error
|
||||
time.sleep(CONNECT_DELAY_SECONDS)
|
||||
raise RecalculationError("uno_pipe_connection_failed") from last_error
|
||||
|
||||
|
||||
def column_name(index):
|
||||
value = index + 1
|
||||
result = ""
|
||||
while value:
|
||||
value, remainder = divmod(value - 1, 26)
|
||||
result = chr(65 + remainder) + result
|
||||
return result
|
||||
|
||||
|
||||
def collect_formula_errors(document):
|
||||
errors = {}
|
||||
formula_flag = uno.getConstantByName(
|
||||
"com.sun.star.sheet.CellFlags.FORMULA"
|
||||
)
|
||||
sheets = document.getSheets()
|
||||
for sheet_name in sheets.getElementNames():
|
||||
if sheet_name.startswith(CANARY_PREFIX):
|
||||
continue
|
||||
sheet = sheets.getByName(sheet_name)
|
||||
formula_ranges = sheet.queryContentCells(formula_flag)
|
||||
for area in formula_ranges.getRangeAddresses():
|
||||
for row in range(area.StartRow, area.EndRow + 1):
|
||||
for column in range(area.StartColumn, area.EndColumn + 1):
|
||||
cell = sheet.getCellByPosition(column, row)
|
||||
error_code = int(cell.getError())
|
||||
if error_code:
|
||||
address = f"{column_name(column)}{row + 1}"
|
||||
errors[f"{sheet_name}!{address}"] = error_code
|
||||
return errors
|
||||
|
||||
|
||||
def find_canary_value(document):
|
||||
sheets = document.getSheets()
|
||||
canaries = [
|
||||
name for name in sheets.getElementNames() if name.startswith(CANARY_PREFIX)
|
||||
]
|
||||
if len(canaries) != 1:
|
||||
raise RecalculationError("recalculation_canary_missing_or_ambiguous")
|
||||
return sheets.getByName(canaries[0]).getCellRangeByName("A1").getValue()
|
||||
|
||||
|
||||
def recalculate(request):
|
||||
input_path = os.path.realpath(os.path.abspath(request["input_path"]))
|
||||
output_path = os.path.realpath(os.path.abspath(request["output_path"]))
|
||||
if input_path == output_path:
|
||||
raise RecalculationError("input_and_output_paths_must_differ")
|
||||
context = connect(request["pipe"])
|
||||
desktop = context.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.frame.Desktop", context
|
||||
)
|
||||
input_url = uno.systemPathToFileUrl(input_path)
|
||||
output_url = uno.systemPathToFileUrl(output_path)
|
||||
load_properties = (
|
||||
property_value("Hidden", True),
|
||||
property_value(
|
||||
"UpdateDocMode",
|
||||
uno.getConstantByName("com.sun.star.document.UpdateDocMode.NO_UPDATE"),
|
||||
),
|
||||
property_value(
|
||||
"MacroExecutionMode",
|
||||
uno.getConstantByName(
|
||||
"com.sun.star.document.MacroExecMode.NEVER_EXECUTE"
|
||||
),
|
||||
),
|
||||
)
|
||||
document = desktop.loadComponentFromURL(input_url, "_blank", 0, load_properties)
|
||||
if document is None:
|
||||
raise RecalculationError("spreadsheet_load_failed")
|
||||
try:
|
||||
document.calculateAll()
|
||||
formula_errors = collect_formula_errors(document)
|
||||
canary_value = find_canary_value(document)
|
||||
document.storeToURL(
|
||||
output_url,
|
||||
(
|
||||
property_value("FilterName", "Calc MS Excel 2007 XML"),
|
||||
property_value("Overwrite", True),
|
||||
),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"formula_error_codes": formula_errors,
|
||||
"canary_value": canary_value,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
document.close(True)
|
||||
except Exception:
|
||||
document.dispose()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
request = json.loads(sys.stdin.readline())
|
||||
# profile_uri is applied by the Node-owned soffice process and retained
|
||||
# in this boundary contract so the caller must identify that isolated profile.
|
||||
required = ("input_path", "output_path", "pipe", "profile_uri")
|
||||
if not isinstance(request, dict) or any(
|
||||
not isinstance(request.get(key), str) or not request[key] for key in required
|
||||
):
|
||||
raise ValueError("invalid_request")
|
||||
response = recalculate(request)
|
||||
except RecalculationError as error:
|
||||
response = {"ok": False, "error": str(error)}
|
||||
except Exception as error:
|
||||
response = {"ok": False, "error": type(error).__name__}
|
||||
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
return 0 if response.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -8,30 +8,33 @@
|
||||
# listed — outbound HTTP must go through the WebFetch/DownloadFile/MCP tools.
|
||||
|
||||
# ── Documents / file-format readers ──────────────────────────
|
||||
pypdf
|
||||
pymupdf>=1.24 # fitz; PDF render/extract (musllinux wheels >=1.24)
|
||||
pdfplumber # PDF text + table extraction (pdfminer.six based)
|
||||
python-docx # .docx read/write
|
||||
python-pptx # .pptx read/write
|
||||
openpyxl # .xlsx read/write
|
||||
xlsxwriter # .xlsx write with formatting/charts
|
||||
xlrd # legacy .xls (pre-2007 Excel) read
|
||||
odfpy # OpenDocument .odt / .ods
|
||||
striprtf # .rtf -> plain text
|
||||
beautifulsoup4 # HTML/XML parsing
|
||||
lxml # fast XML/HTML backend
|
||||
markdownify # HTML -> Markdown
|
||||
markdown # Markdown -> HTML
|
||||
pypdf==6.14.2
|
||||
pymupdf==1.28.0 # fitz; PDF render/extract (musllinux wheels >=1.24)
|
||||
pdfplumber==0.11.10 # PDF text + table extraction (pdfminer.six based)
|
||||
python-docx==1.2.0 # .docx read/write
|
||||
python-pptx==1.0.2 # .pptx read/write
|
||||
openpyxl==3.1.5 # .xlsx read/write
|
||||
xlsxwriter==3.2.9 # .xlsx write with formatting/charts
|
||||
xlrd==2.0.2 # legacy .xls (pre-2007 Excel) read
|
||||
odfpy==1.4.1 # OpenDocument .odt / .ods
|
||||
striprtf==0.0.32 # .rtf -> plain text
|
||||
beautifulsoup4==4.15.0 # HTML/XML parsing
|
||||
lxml==6.1.1 # fast XML/HTML backend
|
||||
markdownify==1.2.3 # HTML -> Markdown
|
||||
markdown==3.10.2 # Markdown -> HTML
|
||||
|
||||
# ── Data / analysis ──────────────────────────────────────────
|
||||
numpy
|
||||
pandas
|
||||
tabulate # render tables as markdown/plain text
|
||||
python-dateutil # flexible date parsing
|
||||
matplotlib # offline charts -> image files in output/ (HEAVY: on
|
||||
# 版は **Python 3.10 で入るもの**に揃えてある。本番が 3.10 で、これより
|
||||
# 新しい numpy / pandas / matplotlib は 3.11 以上を要求する。3.11 以上へ
|
||||
# 上げたら、ここも上げ直してよい(issue #1090)。
|
||||
numpy==2.2.6
|
||||
pandas==2.3.3
|
||||
tabulate==0.10.0 # render tables as markdown/plain text
|
||||
python-dateutil==2.9.0.post0 # flexible date parsing
|
||||
matplotlib==3.10.9 # offline charts -> image files in output/ (HEAVY: on
|
||||
# alpine/musl needs freetype/libpng build deps; fine on host)
|
||||
|
||||
# ── Images / text ────────────────────────────────────────────
|
||||
Pillow # image processing
|
||||
charset-normalizer # robust encoding detection for messy text
|
||||
PyYAML # YAML read/write (stdlib json covers JSON)
|
||||
Pillow==12.3.0 # image processing
|
||||
charset-normalizer==3.4.9 # robust encoding detection for messy text
|
||||
PyYAML==6.0.3 # YAML read/write (stdlib json covers JSON)
|
||||
|
||||
Reference in New Issue
Block a user