LibreOffice Calc で Python から帳票作成(テンプレートファイルの利用)

前回からの続き。

半日ほど調べて、LibreOffice Calc では、印刷用のページテンプレート(ヘッダーやフッターなど)を UNO API からは操作できないことがわかりました。ドラッグ&ドロップでコピーできないのも変だけどマクロからも操作ができませんね。

仕方がないので、あらかじめ印刷用のページテンプレートを設定したファイル(*.ots)を作っておいて、それを利用することにします。別に普通の Calc ファイル(*.ods)を使っても作れます。

ページスタイルの作成

あれこれ探すとページスタイルが作れるようになりました。
「スタイルペインを開く」→「ページスタイルアイコンをクリック」という手順でページスタイル(いわゆるシートに紐づけるスタイル)を作成&設定できます。デフォルトで「標準」と「レポート」というスタイルが作っていあるので、ここでは「帳票テンプレート」というスタイルを作成しました。

ヘッダー、フッターの仕方は Excel の設定と似たような感じです。

確か Excel では印刷プレビューから飛べたはずなのですが、LibreOffice Calc では飛べません。まあ、あまり印刷をしないのかもしれません。ただし、文書として PDF に変換することが多いでしょうから、こういう印刷スタイルも整備してほしいかなと。

このファイルを「report-template.ots」あるいは「report-template.ods」のように保存しておきます。テンプレートファイルの *.ots にしてしまうと、

C:\Users\Tomoaki\AppData\Roaming\LibreOffice\4\user\template\report-template.ots

こんな感じの LibreOffice のテンプレート用のフォルダーに保存されてしまうので結構面倒です。テンプレート文書とはいえ Python マクロから参照するだけなので、プログラムと同じ場所に置いたほうが実験がしやすいと思います。

テンプレート文書を開いて、別名で閉じる

いろいろ試したのですが、以下の手順が一番安定しています。

  1. テンプレート文書を開く
  2. データ等を書き込む
  3. 別名でファイルを保存して閉じる

途中の動作を確認しようかと思って、テンプレート文書のほうの Calc を開いたままにしようかと思ったのですが、Calc が落ちます。どうやら、UI で何か受け付けようとしてうまくいっていないようです。マクロを動かしている Calc を Hidden にしておくと安定します。

import uno
from pathlib import Path

# サーバーの起動
# & "C:\Program Files\LibreOffice\program\soffice" --accept="socket,host=localhost,port=2002;urp;" --norestore --nologo
# PowerShell での起動
# & "C:\Program Files\LibreOffice\program\python" .\calcReport002.py

"""
テンプレートから新規ドキュメントを作成して Calc レポートを生成するサンプル。

手順:
1. 「帳票テンプレート」(report-template.ots) をテンプレートとして新規ドキュメントを開く
2. サンプルデータの行数に合わせて明細行の書式を複製
3. データを書き込む
4. 別名で保存
"""


# 設定: テンプレートと出力先
TEMPLATE_NAME = "report-template.ots"
OUTPUT_NAME = "report-output.ods"
TEMPLATE_SHEET_NAME = "Template"  # テンプレート内の帳票シート名
REPORT_SHEET_NAME = "Report"      # 必要ならこの名前のシートを使用(存在しなければ先頭シートを使う)


def to_file_url(path: Path) -> str:
	return uno.systemPathToFileUrl(str(path.resolve()))


def connect_desktop():
	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 = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
	doc = desktop.getCurrentComponent()
	if doc is None:
		raise RuntimeError("アクティブな Calc ドキュメントが見つかりません")
	return desktop, doc


def load_template(desktop, template_path: Path):
	url = to_file_url(template_path)
	props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	props[0].Name = "Hidden"
	props[0].Value = True
	return desktop.loadComponentFromURL(url, "_blank", 0, props)


def ensure_rows_with_format(sheet, template_row_idx: int, start_row: int, rows_needed: int, last_col: int):
	template_range = sheet.getCellRangeByPosition(0, template_row_idx, last_col, template_row_idx)
	source_addr = template_range.getRangeAddress()
	template_height = sheet.getRows().getByIndex(template_row_idx).Height

	for i in range(rows_needed):
		target_row = start_row + i
		if target_row == template_row_idx:
			continue
		dest_addr = uno.createUnoStruct("com.sun.star.table.CellAddress")
		dest_addr.Sheet = source_addr.Sheet
		dest_addr.Column = 0
		dest_addr.Row = target_row
		sheet.copyRange(dest_addr, source_addr)
		sheet.getRows().getByIndex(target_row).Height = template_height


def write_data(sheet, data, start_row: int = 1, start_col: int = 0):
	for r, row in enumerate(data):
		for c, value in enumerate(row):
			cell = sheet.getCellByPosition(start_col + c, start_row + r)
			if isinstance(value, (int, float)):
				cell.setValue(value)
			else:
				cell.setString(str(value))


def main():
	base_dir = Path(__file__).resolve().parent
	template_path = base_dir / TEMPLATE_NAME
	output_path = base_dir / OUTPUT_NAME

	sample_data = [
		["001", "Alice", "Sales", 1200.5],
		["002", "Bob", "Marketing", 980.0],
		["003", "Carol", "Engineering", 1540.75],
		["004", "Dave", "Support", 760.25],
	]

	if not template_path.exists():
		raise FileNotFoundError(f"テンプレートが見つかりません: {template_path}")

	desktop, _ = connect_desktop()
	doc = load_template(desktop, template_path)

	# 帳票シートを取得(指定名がなければ先頭シート)
	sheets = doc.Sheets
	if sheets.hasByName(REPORT_SHEET_NAME):
		report_sheet = sheets.getByName(REPORT_SHEET_NAME)
	elif sheets.hasByName(TEMPLATE_SHEET_NAME):
		report_sheet = sheets.getByName(TEMPLATE_SHEET_NAME)
	else:
		report_sheet = sheets.getByIndex(0)

	# データ件数に合わせて行を用意(ヘッダー1行 + データ行)
	last_col = len(sample_data[0]) - 1 if sample_data else 0
	ensure_rows_with_format(
		sheet=report_sheet,
		template_row_idx=1,
		start_row=1,
		rows_needed=len(sample_data),
		last_col=last_col,
	)

	# データを書き込み(1行目はヘッダー想定)
	write_data(report_sheet, sample_data, start_row=1, start_col=0)
	# シート名を変更
	report_sheet.Name = "給与レポート"

	# 別名で保存
	store_props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	store_props[0].Name = "FilterName"
	store_props[0].Value = "calc8"
	doc.storeToURL(to_file_url(output_path), store_props)
	print(f"レポートを保存しました: {output_path}")

	# 後処理
	try:
		doc.dispose()
	except Exception:
		pass


if __name__ == "__main__":
	try:
		main()
	except Exception as e:
		print(f"error: {e}")


後で、もう少し業務用の Python コードを整理しますが、肝は以下の部分です。

def load_template(desktop, template_path: Path):
	url = to_file_url(template_path)
	props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	props[0].Name = "Hidden"
	props[0].Value = True
	return desktop.loadComponentFromURL(url, "_blank", 0, props)

ここで Hidden の値を False にすると Calc が表示されるようになりますが、頻繁に Calc が落ちます。UNO API で操作しているときは、UI を止めたほうがよさそうです。

帳票作成

作成できた report-output.ods がこれです。

印刷プレビュー

ヘッダー、フッターの操作はできないので、あらかじめテンプレート文書に入れておきます。マクロから操作できないのは痛いのですが、社名などは固定になるし印刷したときの日時はヘッダー/フッターの機能として用意されているので、あまり不便にはならないでしょう。

複数ページになったときの設定とかは Excel と同じなので、これもいけると思います。

次は、裏にデータシートを設定して関数とかでセル参照をしているときの動作ですね。請求書とかを作るときに便利な方法です。

カテゴリー: 開発, LibreOffice パーマリンク