コード自体は AI エージェント(Copilot、ChatGPTなど)に作って貰うことができます。 最終的には Sheet を扱うことになるので、そのあたりのコード補完ができたらいいのですが、すべてが Any 型になってしまっているのでここをなんとかしないといけません。 で、現在、コード補完用のクラスを作っているわけです。
"""LibreOffice Calc sample macro."""
import inspect
import os
import sys
# Ensure this script's directory is importable so the local excellike package resolves
BASE_DIR = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
if BASE_DIR not in sys.path:
sys.path.append(BASE_DIR)
from excellike.calc.sheet import Sheet
def excellike_write_hello_to_cell():
# LibreOffice のコンテキストを取得
desktop = XSCRIPTCONTEXT.getDesktop()
doc = desktop.getCurrentComponent()
# Calc 以外の文書で実行された場合は無視
if not hasattr(doc, "Sheets"):
return
sheet = doc.Sheets[0] # 最初のシート
sheet = Sheet(sheet)
sheet.setText(0, 0, "Hello from Python!")
g_exportedScripts = (
excellike_write_hello_to_cell,
)
ここでは Sheet という独自のラッパークラスを作っています。
from __future__ import annotations
from ..core import InterfaceNames, UnoObject
class Sheet(UnoObject):
def __init__(self, obj):
self._obj = obj
def setText(self, column: int, row: int, text: str) -> None:
cell = self._obj.getCellByPosition(column, row)
# LibreOffice Calc cells expose setString/String, not setText
if hasattr(cell, "setString"):
cell.setString(text)
else:
cell.String = text
マクロを直接実行する
マクロを直接実行する場合は、「ツール」→「マクロ」→「マクロの実行」を選択します。
先の Scripts/python フォルダーにあるマクロが選択できます。
Calc の内から Python マクロを使う時は、XSCRIPTCONTEXT Calc の外から Python マクロを使う時は、UNO API をポート経由で、
ということになっていて、その後の操作は全く同じです。 なので、この初期処理部分を統一化しておけば、マクロ開発も楽なわけですが。ひとまず、Excel VBA エディタでちまちま開発するよりは、VSCode + Python で開発するのが楽なのでは?というぐらいまでは到達しています。ただし、そのまま UNO API のオブジェクトを使うと全て Any 型なので、入力候補が全くでません。 このあたりのサポートは必須ですね。
経費処理の場合はもう少し長いスパンで決済のタイミングが異なります。たとえば、同じ PC を買ったとしても購入時期により価格が変わったり消費税が変わったりすることはよくあります。 この経費精算を後から修正するパターンを考えてみましょう。社員が経費精算をするときに入力した値はなんらかの形で保持しておいて、後から経理部門や税理士が修正するパターンです。
物品購入テーブル
経費精算申請
経費精算承認フロー
経費精算の修正
会計仕分けテーブル
経費項目マスタ
消費税マスタ
-- 物品購入テーブル
CREATE TABLE purchases (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL,
purchase_date DATE NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
tax_rate DECIMAL(5, 2) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_employee_id (employee_id),
INDEX idx_purchase_date (purchase_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 経費精算申請
CREATE TABLE expense_claims (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL,
purchase_id INT NOT NULL,
claimed_amount DECIMAL(10, 2) NOT NULL,
expense_category_id INT NOT NULL,
claim_date DATE NOT NULL,
status ENUM('draft', 'submitted', 'approved', 'rejected') DEFAULT 'draft',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
INDEX idx_employee_id (employee_id),
INDEX idx_purchase_id (purchase_id),
INDEX idx_status (status),
FOREIGN KEY (purchase_id) REFERENCES purchases(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 経費精算承認フロー
CREATE TABLE expense_approvals (
id INT PRIMARY KEY AUTO_INCREMENT,
expense_claim_id INT NOT NULL,
approver_id INT NOT NULL,
approval_status ENUM('pending', 'approved', 'rejected') DEFAULT 'pending',
comment TEXT,
approved_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_expense_claim_id (expense_claim_id),
INDEX idx_approver_id (approver_id),
FOREIGN KEY (expense_claim_id) REFERENCES expense_claims(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 経費精算の修正履歴
CREATE TABLE expense_revisions (
id INT PRIMARY KEY AUTO_INCREMENT,
expense_claim_id INT NOT NULL,
original_amount DECIMAL(10, 2) NOT NULL,
revised_amount DECIMAL(10, 2) NOT NULL,
reason TEXT,
revised_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_expense_claim_id (expense_claim_id),
FOREIGN KEY (expense_claim_id) REFERENCES expense_claims(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 会計仕分けテーブル
CREATE TABLE journal_entries (
id INT PRIMARY KEY AUTO_INCREMENT,
expense_claim_id INT NOT NULL,
account_code VARCHAR(20) NOT NULL,
debit DECIMAL(10, 2) DEFAULT 0,
credit DECIMAL(10, 2) DEFAULT 0,
entry_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_expense_claim_id (expense_claim_id),
INDEX idx_account_code (account_code),
FOREIGN KEY (expense_claim_id) REFERENCES expense_claims(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 経費項目マスタ
CREATE TABLE expense_categories (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
account_code VARCHAR(20),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 消費税マスタ
CREATE TABLE tax_rates (
id INT PRIMARY KEY AUTO_INCREMENT,
rate DECIMAL(5, 2) NOT NULL,
effective_date DATE NOT NULL,
expired_date DATE,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
これも AI に作って貰ったパターンなので、 deleted_at カラムをチェックします。経費精算申請テーブルや経費項目マスタに入っていますね。
要は、自治体の住民情報は「調べたときのその時の情報」になります。過去の遡って転出してしまった住民の情報を調べることは…できなくもないのですが、それらを Web API で提供するかどうかという話になります。ガバクラで、その API 仕様があったのがそのあたりの難点になります。
自治体での住民情報の管理
住民の入退出の情報
社会保険の加入、脱退の情報
-- 自治体の住民情報スキーマ例
CREATE TABLE residents ( -- 住民基本台帳
id BIGINT PRIMARY KEY AUTO_INCREMENT,
resident_id VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(128) NOT NULL,
address VARCHAR(255) NOT NULL,
birth_date DATE NOT NULL,
status ENUM('active', 'moved_out', 'deceased') DEFAULT 'active',
registered_at TIMESTAMP NOT NULL,
status_changed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
CREATE TABLE resident_histories ( -- 住民の入退出履歴
id BIGINT PRIMARY KEY AUTO_INCREMENT,
resident_id BIGINT NOT NULL,
event_type ENUM('registration', 'move_in', 'move_out', 'status_change') NOT NULL,
address VARCHAR(255) NULL,
event_date TIMESTAMP NOT NULL,
remarks TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (resident_id) REFERENCES residents(id)
);
CREATE TABLE insurance_records ( -- 社会保険加入履歴
id BIGINT PRIMARY KEY AUTO_INCREMENT,
resident_id BIGINT NOT NULL,
insurance_type VARCHAR(64) NOT NULL,
status ENUM('enrolled', 'withdrawn') DEFAULT 'enrolled',
enrolled_date DATE NOT NULL,
withdrawn_date DATE NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (resident_id) REFERENCES residents(id)
);
residents 住民基本台帳には deleted_at カラムを設けて論理削除を可能にしていますが、実際にはステータス status カラムでチェックすることになります。このテーブルでは、住民が自治体にいるときは「active」になるので、必ず where status = ‘active’ の検索が必要になります。 例えば、住民が 30 万人程度だとしても、転出や転入、死亡なども含めれば毎年結構な量になりますよね。大抵は active な筈ですが、長年使っていると active 以外が多くなりそうな可能性が高いです。
-- residents.resident_id を指定して、住民の入退出履歴と社会保険加入履歴を取得する例
SELECT rh.*, ir.*
FROM residents r
LEFT JOIN resident_histories rh ON r.id = rh.resident_id
LEFT JOIN insurance_records ir ON r.id = ir.resident_id
WHERE r.resident_id in ( 'some_resident_id', 'another_resident_id' )
AND r.status = 'active';
-- 過去の部屋の予約状況を調べる例
SELECT r.room_number, res.check_in_date, res.check_out_date, res.status
FROM rooms r
LEFT JOIN reservations res ON r.id = res.room_id
WHERE r.room_number = '101'
AND res.status = 'completed' -- 完了した予約のみ
AND res.check_in_date between '2023-01-01' AND '2023-12-31'
ORDER BY res.check_in_date DESC;
過去の場合は is_delete のチェックを外します。
逆に言えば、現在利用できる rooms のリストでは is_deleted チェックが必須です。
-- 現在利用可能な部屋のリストを取得する例
SELECT *
FROM rooms
WHERE deleted_at IS NULL;
自分が業務で使っているパターンとしては網羅できたので、あとは使い勝手です。これらの Python コードはほとんどが AI エージェントで作成して貰っているので、手が出しずらい状態になっています。あくまで使い捨てのコーディングになっているので、細かいところを修正しようとするとプロンプトから手をいれることになってしまいます。
肝は、Excel + ClosedXML の組み合わせが、LibreOffice Calc + UNO API でできるか?ということです。私としては C# から UNO API を使えればよいのですが、前記事から C# から UNO API を使うには難点があるので、素直に Python でコーディングをします。
まあ、コーディングをするといってもベースを作るのは AI エージェントなので、それを動かしてちょこちょこと Python コードを修正することになります。
LibreOffice の UNO API は戻り値が Any なのでコード補完が効きません。多分、戻り値が object なので、そういう仕様になっているのでしょうが、コードを書くときに面倒です。というか、どのメソッドを呼び出せるのか実行時にしかわかりません。 ひょっとしてメソッド名を全部覚えているのか? とも思わなくもないのですが、もっと敷居を下げておきたいです。自分のためにも。
おそらく、C++、Java で UNO API を使うためには大量のインターフェースを定義してあるはずです。
import uno
import re
from typing import Any, TYPE_CHECKING, cast
# typings/calc.pyi は型チェック専用に読み込む(実行時はフォールバック)
if TYPE_CHECKING:
from calc import XDesktop, XComponent, XSpreadsheet, XCellRange, Rectangle # type: ignore
else:
XDesktop = XComponent = XSpreadsheet = XCellRange = Rectangle = Any
"""
棒グラフを作成するスクリプト
"""
def connect_to_libreoffice() -> tuple[XDesktop, XComponent, XSpreadsheet]:
"""LibreOffice に接続してアクティブな Calc シートを取得する"""
local_ctx = uno.getComponentContext()
resolver = local_ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.bridge.UnoUrlResolver", local_ctx)
ctx = resolver.resolve(
"uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
smgr = ctx.ServiceManager
desktop : XDesktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
model = desktop.getCurrentComponent()
if model is None:
raise RuntimeError("LibreOffice Calc ドキュメントが開かれていません")
sheet = model.getCurrentController().getActiveSheet()
return desktop, model, sheet
if TYPE_CHECKING のところで、コーディング時と実行時の切り替えをしなといけないのが面倒なのですが、これで Any となっている型を無理矢理 XCellRange などの型に変えることができます。 本来は、cast 関数を使って正式にキャストをすればいいのですが、どうせ Any からのキャストでしかないので、変数の型だけ決めて、そこに無理やり押しこみます。
UNO API のクラスは膨大にあって、ちまちま手作業で変換するのは大変です。いや、そもそも使いたいのは Calc 関係だけなので、全部を変換するのは大袈裟だし、手間がかかります。 基本は AI エージェントにコードを作って貰うつもりなので、すべての型が必要なわけではありません。ちょっと手直しをするとか、数行のマクロコードを書くときにコード補完ができればよいのです。
C++ や Java のライブラリを利用すればいいのでは?
UNO API のインターフェースは、Python だけではありません。C++ や Java からも使うことができます。ドキュメントは非常に少ないですが、C# から、つまり .NET からのアクセスも可能となっています。
結論から言うと現状の 25.8.3 では動作しません。これは .NET 関係で作られているラッパーがおかしいのか、プログラムがおかしいのか不明ですが、UNO API サーバーに繋がりません。
using System;
using System.Diagnostics;
using uno;
using uno.util;
using unoidl.com.sun.star.bridge;
using unoidl.com.sun.star.container;
using unoidl.com.sun.star.frame;
using unoidl.com.sun.star.lang;
using unoidl.com.sun.star.sheet;
using unoidl.com.sun.star.table;
using unoidl.com.sun.star.text;
using unoidl.com.sun.star.uno;
using unoidl.com.sun.star.beans;
class Program
{
static void Main()
{
try
{
Console.WriteLine("Starting...");
string[] programPathCandidates =
{
@"C:\\Program Files\\LibreOffice\\program",
@"C:\\Program Files (x86)\\LibreOffice\\program"
};
string? libreOfficeProgramPath = null;
foreach (var p in programPathCandidates)
{
if (System.IO.Directory.Exists(p))
{
libreOfficeProgramPath = p;
break;
}
}
if (libreOfficeProgramPath == null)
{
Console.WriteLine("LibreOffice program folder not found. Please adjust path.");
foreach (var p in programPathCandidates)
{
Console.WriteLine($"Tried: {p}");
}
return;
}
Console.WriteLine($"UNO_PATH target: {libreOfficeProgramPath}");
// Ensure UNO can locate LibreOffice binaries.
Environment.SetEnvironmentVariable("UNO_PATH", libreOfficeProgramPath, EnvironmentVariableTarget.Process);
var bootstrapIni = System.IO.Path.Combine(libreOfficeProgramPath, "fundamental.ini");
Environment.SetEnvironmentVariable("URE_BOOTSTRAP", $"vnd.sun.star.pathname:{bootstrapIni}", EnvironmentVariableTarget.Process);
string ureBin = System.IO.Path.Combine(libreOfficeProgramPath, "..\\URE\\bin");
string newPathSegment = libreOfficeProgramPath + ";" + ureBin;
string? currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
if (!currentPath.Contains(libreOfficeProgramPath, StringComparison.OrdinalIgnoreCase) || !currentPath.Contains(ureBin, StringComparison.OrdinalIgnoreCase))
{
Environment.SetEnvironmentVariable("PATH", newPathSegment + ";" + currentPath, EnvironmentVariableTarget.Process);
}
Environment.SetEnvironmentVariable("UNO_SERVICES", System.IO.Path.Combine(libreOfficeProgramPath, "uno_services.rdb"), EnvironmentVariableTarget.Process);
// Some environments need the working directory inside the LibreOffice program folder.
Environment.CurrentDirectory = libreOfficeProgramPath;
// Start (or reuse) LibreOffice headless with a socket connector.
var sofficePath = System.IO.Path.Combine(libreOfficeProgramPath, "soffice.exe");
var acceptArg = "--accept=socket,host=localhost,port=2002;urp;StarOffice.ServiceManager";
if (!System.IO.File.Exists(sofficePath))
{
Console.WriteLine($"soffice.exe not found at {sofficePath}");
return;
}
bool sofficeRunning = Process.GetProcessesByName("soffice.bin").Length > 0;
if (!sofficeRunning)
{
Console.WriteLine("Starting soffice headless...");
var psi = new ProcessStartInfo
{
FileName = sofficePath,
Arguments = $"--headless --nologo --norestore --nodefault {acceptArg}",
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = libreOfficeProgramPath
};
Process.Start(psi);
// Give soffice time to open the socket.
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(5));
}
Console.WriteLine("Creating initial UNO context...");
XComponentContext localContext = Bootstrap.defaultBootstrap_InitialComponentContext();
XMultiComponentFactory localSmgr = localContext.getServiceManager();
XUnoUrlResolver resolver = (XUnoUrlResolver)localSmgr.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", localContext);
Console.WriteLine("Resolving remote context via socket...");
XComponentContext remoteContext = (XComponentContext)resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext");
XMultiComponentFactory serviceManager = remoteContext.getServiceManager();
XDesktop? desktop = serviceManager.createInstanceWithContext("com.sun.star.frame.Desktop", remoteContext) as XDesktop;
if (desktop == null)
{
Console.WriteLine("Failed to acquire LibreOffice desktop.");
return;
}
Console.WriteLine("Desktop acquired");
XComponentLoader? loader = desktop as XComponentLoader;
if (loader == null)
{
Console.WriteLine("Failed to acquire component loader.");
return;
}
Console.WriteLine("Component loader OK");
// Reuse the active Calc document, or create a new one if none is open.
Console.WriteLine("Checking current component...");
XSpreadsheetDocument? calcDocument = desktop.getCurrentComponent() as XSpreadsheetDocument;
if (calcDocument == null)
{
Console.WriteLine("No active Calc. Creating new...");
var args = Array.Empty<PropertyValue>();
var component = loader.loadComponentFromURL("private:factory/scalc", "_blank", 0, args);
calcDocument = component as XSpreadsheetDocument;
}
if (calcDocument == null)
{
Console.WriteLine("No Calc document available.");
return;
}
Console.WriteLine("Calc document ready");
// Access the first sheet and write text to cell A1.
XSpreadsheets sheets = calcDocument.getSheets();
XIndexAccess sheetAccess = (XIndexAccess)sheets;
Any sheetAny = (Any)sheetAccess.getByIndex(0);
XSpreadsheet? sheet = sheetAny.Value as XSpreadsheet;
if (sheet == null)
{
Console.WriteLine("Failed to access the first sheet.");
return;
}
Console.WriteLine("Sheet acquired");
XCell cell = sheet.getCellByPosition(0, 0);
cell.setFormula("Hello by C#");
Console.WriteLine("Value written to A1.");
}
catch (System.Exception ex)
{
Console.WriteLine($"Failed to write to Calc: {ex.Message}");
Console.WriteLine(ex);
}
}
}
結果
PS H:\LibreOffice\net-ref> dotnet run
Starting...
UNO_PATH target: C:\\Program Files\\LibreOffice\\program
Creating initial UNO context...
Failed to write to Calc: External component has thrown an exception.
System.Runtime.InteropServices.SEHException (0x80004005): External component has thrown an exception.
at cppu.defaultBootstrap_InitialComponentContext(Reference<com::sun::star::uno::XComponentContext>*)
at uno.util.Bootstrap.defaultBootstrap_InitialComponentContext(String ini_file, IDictionaryEnumerator bootstrap_parameters)
at uno.util.Bootstrap.defaultBootstrap_InitialComponentContext()
at Program.Main() in H:\LibreOffice\net-ref\Program.cs:line 97
PS H:\LibreOffice\net-ref>
Sub test()
Dim sh As Worksheet
Set sh = ActiveSheet
sh.Cells(1, 1).Value = "増田智明"
End Sub
エラーになるので ChatGPT でコンバートすると以下のようになります。
Sub test
Dim oDoc As Object
Dim oSheet As Object
Dim oCell As Object
' 現在のドキュメントを取得
oDoc = ThisComponent
' アクティブシートを取得
oSheet = oDoc.CurrentController.ActiveSheet
' A1セルを取得 (行・列は0始まり)
oCell = oSheet.getCellByPosition(0, 0)
' 値を設定
oCell.String = "増田智明"
End Sub
Lib を参照している Python のバージョン部分「python-core-3.11.13」を開発環境で揃えないといけませんが、これで import uno のコード補完が有効になります。 ただし、もともと Any 型になっているものが多いのか、あまり役に立たないのですが、メソッド名とかクラス名からライブラリのコードにジャンプできるのはいいかもしれません。