技術構成とデータフロー
本手法は、テキスト・多次元テーブル・チャート画像が混在するレポートをプログラムで自動生成するための実装例です。前端でChart.jsやEChartsなどのレンダリング結果をBase64画像に変換し、階層化された構造データと共に非同期通信で送信します。バックエンドではNode.jsのファイルシステム操作用APIとofficegenライブラリを組み合わせて、定義済みテンプレートに従ってXML構造を構築し、バイナリストリームとしてブラウザへ配信します。
前端:チャート画像化とペイロード送信
EChartsインスタンスを取得し、getConnectedDataURL APIを介してPNG形式へエクスポートします。取得したURL文字列からBase64データを抽出し、フォームデータとしてバックエンドへ送信する際、DOM操作は標準Fetch APIへ移行しています。
const exportChartsToImages = async () => {
const chartDomList = Array.from(document.querySelectorAll('.chart-container'));
const payload = { pageId: 'financial_report', images: [], chartIds: [] };
for (let index = 0; index < chartDomList.length; index++) {
const domInstance = echarts.getInstanceByDom(chartDomList[index]);
if (!domInstance) continue;
const parentId = domInstance._dom.offsetParent?.classList[1] || '';
const rawUrl = await new Promise((resolve) => {
domInstance.getConnectedDataURL({ pixelRatio: 2, backgroundColor: '#fff', type: 'png' }, resolve);
});
// Base64プレフィックス除去
const base64Data = rawUrl.replace(/^data:image\/\w+;base64,/, '');
payload.images.push({ src: base64Data });
payload.chartIds.push({ id: parentId.split('_').pop() });
}
return submitReportPayload(payload);
};
const submitReportPayload = async (payload) => {
try {
const response = await fetch('/api/reports/generate-word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
window.location.href = '/api/reports/download';
} else {
console.error('レポート生成に失敗しました');
}
} catch (error) {
console.error('通信エラー:', error);
}
};
バックエンド:画像保存とエントリポイント
Koa/Express互換のミドルウェア構成で受診を受け付けます。画像データはfs.promisesモジュールにより同期的/非同期的なディレクトリ作成とファイル書き込みを行います。旧来のnew Buffer()はセキュリティ上の懸念があるため、推奨されるBuffer.from()へ置き換えています。
const fs = require('fs/promises');
const path = require('path');
const saveGeneratedAssets = async (ctx) => {
const { pageId, images } = ctx.request.body;
const assetPath = path.resolve(`./reports/${pageId}`);
await fs.mkdir(assetPath, { recursive: true });
for (const img of images) {
const buf = Buffer.from(img.src, 'base64');
const fileName = `viz_${Date.now()}.png`;
await fs.writeFile(path.join(assetPath, fileName), buf);
}
ctx.status = 200;
ctx.body = { status: 'ready', dir: assetPath };
};
module.exports = { saveGeneratedAssets };
ドキュメント生成ロジック(Officegen統合)
受信したJSON構造をofficegenが認識可能な配列形式へマッピングします。この段階で以下の処理を行っています:
- 見出しレベルに応じたフォントサイズ・太さの設定
- HTMLエンティティのデコードと改行正規化
- 表組みの横幅均等割り当てと
gridSpan/vMerge属性の適用 - 画像の埋め込み時にアスペクト比を維持しつつ最大幅でリサイズ
const officegen = require('officegen');
const sizeOf = require('image-size');
const buildDocxTree = (rawData, assetPath) => {
let docElements = [];
// ルート見出し
docElements.push({
type: 'text', opt: { bold: true, font_size: 24 }, val: rawData.title
});
const traverseSections = (sections) => {
sections.forEach(section => {
docElements.push({ type: 'text', opt: { bold: true, font_size: 20 }, val: section.heading });
// テキストセクション(HTMLタグ除去)
const cleanText = section.content
?.replace(/<br\s*?\/?>/g, '\n')
.replace(/<[^>]+>/g, '')
.trim();
if (cleanText) docElements.push({ type: 'text', val: cleanText });
// チャート画像埋め込み
if (section.chartAsset) {
docElements.push(resizeEmbedImage(`${assetPath}/${section.chartAsset}`));
}
// テーブル処理
if (section.tableData) {
docElements.push(buildStyledTable(section.tableData));
}
// ネストされたサブアイテム再帰呼び出し
if (Array.isArray(section.children)) traverseSections(section.children);
});
};
if (Array.isArray(rawData.sections)) traverseSections(rawData.sections);
return docElements;
};
const resizeEmbedImage = (filePath) => {
const dimensions = sizeOf(filePath);
const maxW = 600;
const ratio = dimensions.width > maxW ? maxW / dimensions.width : 1;
return {
type: 'image',
path: filePath,
opt: { cx: Math.floor(dimensions.width * ratio), cy: Math.floor(dimensions.height * ratio) }
};
};
const applyCellMerges = (row, totalCols) => {
return row.map(cell => {
let opts = { align: 'center', shd: { fill: 'E8F4FA' } };
if (cell.spanCol) opts.gridSpan = cell.spanCol;
if (cell.mergeVert === 'restart') opts.vMerge = 'restart';
if (cell.mergeVert === 'continue') opts.vMerge = 'continue';
// 均等分割計算
const usedWidth = totalCols * 1000;
opts.cellColWidth = opts.gridSpan ? Math.floor(usedWidth / totalCols) : undefined;
return { val: cell.text, opts };
});
};
const buildStyledTable = (tableConfig) => {
const { headers, body, spans } = tableConfig;
const columnsCount = headers.length;
const rows = [];
// ヘッダー行
rows.push(applyCellMerges(headers, columnsCount));
// ボディ行
body.forEach(row => rows.push(applyCellMerges(row, columnsCount)));
return {
type: 'table',
opt: { borders: true, borderSize: 1, tableAlign: 'left' },
val: rows
};
};
const generateDocxFile = async (structure, outputPath) => {
return new Promise((resolve, reject) => {
const generator = officegen({ type: 'docx', title: 'Report_Generation' });
const tree = buildDocxTree(structure, './reports');
generator.createByJson(tree);
const outputStream = fs.createWriteStream(outputPath);
generator.generate(outputStream, {
finalize: () => console.log('DOCX生成完了'),
error: reject
});
outputStream.on('finish', () => resolve(outputPath));
});
};
配信エンドポイント設定
生成されたWordファイルをHTTPヘッダー付きでストリーミング配信します。FilenameエンコーディングはRFC6266準拠の処理を適用し、ブラウザのダウンロードダイアログを正しく動作させます。
const mime = require('mime-types');
const sendFile = require('koa-send'); // または express.sendFile
const deliverDocument = async (ctx) => {
const filePath = await generateDocxFile(ctx.request.body.structure, './temp/output.docx');
const encodedName = encodeURIComponent('generated_report.docx');
ctx.set('Content-Type', mime.lookup(filePath) || 'application/octet-stream');
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`);
await sendFile(ctx, filePath);
await fs.unlink(filePath); // 配信後の一時ファイル削除
};
実装上の注意点
officegenのJSONスキーマでは、表の1セル当たりのコンテンツをvalフィールドに必須で格納する必要があります。空セルも明示的にオブジェクトとして定義してください。- 大量のハイイメージを連続埋め込む場合、メモリフットプリントが増大するため、
createWriteStreamの内部バッファ調整や、圧縮PNG変換プロセスを間に挟むことが推奨されます。 - 結合セル属性(
gridSpan,vMerge)はExcel互換仕様に準拠しているため、開始位置にrestart、継続位置にcontinueを指定することで、正しくXMLタグへ展開されます。