Node.jsとOfficegenを活用した複合型レポートのWordファイル生成・配信実装

技術構成とデータフロー

本手法は、テキスト・多次元テーブル・チャート画像が混在するレポートをプログラムで自動生成するための実装例です。前端で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タグへ展開されます。

タグ: officegen docx-generation node-js table-rowspan-colspan echart-to-png

9月5日 22:45 投稿