8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

吾輩は人間である

前書き

最近の業務で、Mastra製のAIエージェントにPPTXファイルの画像文字を認識させる必要がある要件があり、外部の変換サービスを利用せずに実装した手順を記録するものです。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_huajc7ltzr3797z9ogcd.jpg

試した事

1. Citations機能の利用

Amazon BedrockからSonnet 4.5を使用し、画像も読み取れるとされるCitations機能を試してみました。しかし、テキストは認識できるものの、画像情報は抽出されていないようでした。

当初の発表ではClaude Opus 4、Claude Sonnet 4、Claude Sonnet 3.7、Claude Sonnet 3.5v2が対応しているとのことだったため、Claude Sonnet 4.5はまだ未対応の可能性もあります。

実装時のサンプルは下記のAI SDKから確認できます。

2. Mastraのメッセージに直接ファイルを渡す方法

MastraのUserModelMessageはファイル形式も対応していたため、試してみましたが、
それでも画像情報が抜けていました。
code.png

上記の方法は2025年10月22日時点ではできませんでしたが、私の使い方が間違っている可能性もあります。また、今回はpptx→pdf→画像という流れで変換しましたが、pptxから直接画像に変換すれば結果が変わるかもしれません。:point_up_tone1:

結論

Next.jsにMastraを組み込んで使う前提で、PPTXを画像に変換する方法を調査した結果、以下の組み合わせを採用しました。

変換後のPNG画像(N枚)をimageBase64形式に変換し、MastraのAIエージェントのメッセージリストに追加することで、画像形式でデータを渡します。

採用理由

  • LibreOffice - オープンソースでPPTX→PDF変換が安定している。ローカル環境(Mac)でもFargateでも動作する
  • ImageMagick - PDF→PNG変換で実績があり、densityオプションで解像度調整が可能
    • Ghostscript - ImageMagickがPDFを処理するために必要

変換用のサンプルコードは以下の通りです。

pptx-image-converter.ts
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { tmpdir, platform } from 'os';
import { randomUUID } from 'crypto';

/**
 * 変換されたスライド画像
 */
export interface ConvertedSlide {
  /**
   * スライド番号(1から開始)
   */
  slideNumber: number;

  /**
   * Base64エンコードされた画像データ
   * 形式: "data:image/png;base64,xxx"
   */
  imageBase64: string;

  /**
   * MIMEタイプ
   * 例: "image/png"
   */
  mediaType: string;
}

/**
 * PPTX画像変換のオプション
 */
export interface PptxImageConverterOptions {
  /**
   * 画像の解像度(DPI)
   * デフォルト: 150
   */
  density?: number;

  /**
   * 一時ディレクトリのパス
   * デフォルト: システムの一時ディレクトリ
   */
  tempDir?: string;
}

/**
 * プラットフォームに応じたLibreOfficeのコマンドを取得
 */
function getLibreOfficeCommand(): string {
  const osType = platform();

  if (osType === 'darwin') {
    // macOS
    return '/Applications/LibreOffice.app/Contents/MacOS/soffice';
  } else {
    // Linux (Docker環境)
    return 'libreoffice';
  }
}

/**
 * PPTXファイルを各スライドの画像に変換する
 *
 * @param pptxBuffer PPTXファイルのBuffer
 * @param options 変換オプション
 * @returns 各スライドの画像データの配列
 *
 * @throws {Error} PDF変換に失敗した場合
 * @throws {Error} 画像変換に失敗した場合
 * @throws {Error} LibreOffice または ImageMagick が見つからない場合
 */
export async function convertPptxToImages(
  pptxBuffer: Buffer,
  options: PptxImageConverterOptions = {},
): Promise<ConvertedSlide[]> {
  const { density = 150, tempDir = tmpdir() } = options;
  const sessionId = randomUUID();
  const inputDir = path.join(tempDir, `pptx-input-${sessionId}`);
  const outputDir = path.join(tempDir, `pptx-output-${sessionId}`);
  const inputFile = path.join(inputDir, 'presentation.pptx');
  const pdfFile = path.join(outputDir, 'presentation.pdf');

  try {
    // ベースの一時ディレクトリが存在しない場合は作成(Fargate環境対応)
    if (!fs.existsSync(tempDir)) {
      fs.mkdirSync(tempDir, { recursive: true });
    }

    // 入力・出力ディレクトリを作成
    fs.mkdirSync(inputDir, { recursive: true });
    fs.mkdirSync(outputDir, { recursive: true });

    // PPTXファイルを一時ファイルとして保存
    fs.writeFileSync(inputFile, pptxBuffer);

    // Step 1: PPTX → PDF (LibreOfficeを使用)
    const libreOfficeCmd = getLibreOfficeCommand();
    const sofficeCommand = `"${libreOfficeCmd}" --headless --convert-to pdf --outdir "${outputDir}" "${inputFile}"`;

    try {
      execSync(sofficeCommand, { encoding: 'utf-8' });
    } catch (error: any) {
      throw new Error(
        `LibreOfficeによるPDF変換に失敗しました。LibreOfficeがインストールされているか確認してください。詳細: ${error?.message}`,
      );
    }

    // PDFファイルが生成されたか確認
    if (!fs.existsSync(pdfFile)) {
      throw new Error('PDF conversion failed: PDF file not created');
    }

    // Step 2: PDF → PNG (ImageMagickを使用)
    const convertCommand = `convert -density ${density} "${pdfFile}" "${path.join(outputDir, 'slide-%03d.png')}"`;

    try {
      execSync(convertCommand, { encoding: 'utf-8' });
    } catch (error: any) {
      throw new Error(
        `ImageMagickによる画像変換に失敗しました。ImageMagickがインストールされているか確認してください。詳細: ${error?.message}`,
      );
    }

    // 出力ディレクトリから画像ファイルを読み込む
    const files = fs.readdirSync(outputDir);
    const imageFiles = files
      .filter((file) => file.endsWith('.png'))
      .sort((a, b) => {
        // ファイル名でソート(スライド順を保持)
        const numA = parseInt(a.match(/\d+/)?.[0] || '0');
        const numB = parseInt(b.match(/\d+/)?.[0] || '0');
        return numA - numB;
      });

    if (imageFiles.length === 0) {
      throw new Error('画像ファイルが生成されませんでした');
    }

    // 各画像をBase64エンコード
    const slides: ConvertedSlide[] = imageFiles.map((file, index) => {
      const imagePath = path.join(outputDir, file);
      const imageBuffer = fs.readFileSync(imagePath);
      const imageBase64 = `data:image/png;base64,${imageBuffer.toString('base64')}`;

      return {
        slideNumber: index + 1,
        imageBase64,
        mediaType: 'image/png',
      };
    });

    return slides;
  } catch (error: any) {
    throw error;
  } finally {
    // クリーンアップ: 一時ディレクトリとファイルを削除
    try {
      if (fs.existsSync(inputDir)) {
        fs.rmSync(inputDir, { recursive: true, force: true });
      }
      if (fs.existsSync(outputDir)) {
        fs.rmSync(outputDir, { recursive: true, force: true });
      }
    } catch (cleanupError: any) {
      // クリーンアップエラーは無視
    }
  }
}

実際にAIエージェントに渡す際は、メッセージを適切なリスト形式にして、ModelMessageタイプに合わせて渡します。

ai-agent.ts
import { z } from 'zod';
import { createStep } from '@mastra/core/workflows';
import { reviewAgent } from '@/mastra/agents/review-agent';
import type { ModelMessage } from 'ai';

// 入力データのスキーマ定義
export const InputSchema = z.object({
  slideImages: z.array(
    z.object({
      slideNumber: z.number(),
      imageBase64: z.string(),
      mediaType: z.string(),
    }),
  ),
});

// 型定義のエクスポート
export type Input = z.infer<typeof InputSchema>;

// Mastra Step定義
export const generationStep = createStep({
  id: 'xx-id',
  inputSchema: InputSchema,
  outputSchema: OutputSchema,
  execute: async ({ inputData, writer, runtimeContext, mastra }) => {
    const logger = mastra.getLogger();
    const { slideImages } = inputData;

    try {
      // マルチモーダルメッセージを構築
      const content: any[] = [
        {
          type: 'text',
          text: `この${slideImages.length}枚のPowerPointスライドを確認してください。`,
        },
        ...slideImages.map((slide) => ({
          type: 'image',
          image: slide.imageBase64,
        })),
      ];

      const messages: ModelMessage[] = [{ role: 'user', content }];

      const streamResult = await reviewAgent.streamVNext(messages, { runtimeContext });

...

ローカル環境では、必要なライブラリをインストールするだけで動作します。

brew install --cask libreoffice

brew install imagemagick

ECSで動作させるための条件

コンテナ環境にモジュールをインストールする必要があるため、ベースイメージをslim版にする必要があります。

FROM node:24-slim AS runner

# LibreOffice, ImageMagick, Ghostscriptをインストール
RUN apt-get update && apt-get install -y --no-install-recommends \
    libreoffice \
    imagemagick \
    ghostscript \
    && rm -rf /var/lib/apt/lists/* \
    # ImageMagickのPDFセキュリティポリシーを緩和
    && sed -i 's/<policy domain="coder" rights="none" pattern="PDF" \/>/<policy domain="coder" rights="read|write" pattern="PDF" \/>/g' /etc/ImageMagick-6/policy.xml

ImageMagickはデフォルトでPDFの読み書きが制限されているため、/etc/ImageMagick-6/policy.xmlの修正が必要

また、一時ファイルを作成する必要があるため、ECSのtask definitionのreadonlyRootFilesystemをfalseにする必要があります。

resource "aws_ecs_task_definition" "main" {
  container_definitions = jsonencode([{
    # ✅ readonlyRootFilesystemを無効化
    readonlyRootFilesystem = false
    ...
  }])
}

セキュアにするための作法

readonlyRootFilesystem = falseにすると、Security Hubからアラートが発生する場合があります。そこで、readonlyRootFilesystem = trueのままでも対応できる方法を紹介します。

ECSのtask_definitionを修正して、/tmpに対してmountPointsを追加します。

  resource "aws_ecs_task_definition" "main" {
    container_definitions = jsonencode([{
      # 読み取り専用ルートファイルシステムを有効化
      readonlyRootFilesystem = true

      # 書き込みが必要な領域はエフェメラルストレージにマウント
      mountPoints = [{
        sourceVolume  = "temp-storage"
        containerPath = "/tmp"
        readOnly      = false
      }]
    }])

    # エフェメラルストレージ設定
    ephemeral_storage {
      size_in_gib = 30
    }

    # 一時ストレージボリューム定義
    volume {
      name = "temp-storage"
    }
  }

これだけではデフォルトでnodeユーザー権限が不足しているため、Dockerfileの修正とentrypoint.shの追加が必要です。

entrypoint.sh
#!/bin/bash
set -e

# /tmpのパーミッション設定
chmod 1777 /tmp
chown node:node /tmp

# HOMEを/tmpのサブディレクトリに設定
# LibreOfficeの.cache、.configなど全ての書き込みを/tmp配下に集約
export HOME=/tmp/home
mkdir -p $HOME
chown node:node $HOME

# nodeユーザーでアプリケーション起動
exec gosu node env HOME=$HOME node server.js

Dockerfileでそのentrypoint.shを呼び出すようにします。

...
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

これでreadonlyRootFilesystem = trueのままでLibreOfficeが使えるようになりました。

注意事項

Base64画像のサイズ制限

Claude APIには最大200KB/画像の制限があります。高解像度のスライドをそのまま送信すると超過する可能性があります。

対策:

export async function convertPptxToImages(
  pptxBuffer: Buffer,
  options: PptxImageConverterOptions = {},
): Promise<ConvertedSlide[]> {
  const { density = 150 } = options;  // ✅ 150 DPIで適度な品質とサイズ

  execSync(
    \`convert -density \${density} "\${pdfFile}" "\${outputPath}"\`,
    { encoding: 'utf-8' }
  );
}

密度設定の目安:

  • density = 72 - 低品質、小サイズ(~50KB/スライド)
  • density = 150 - 中品質、中サイズ(~100-150KB/スライド)⭐ 推奨
  • density = 300 - 高品質、大サイズ(~300KB/スライド)⚠️ 制限超過の可能性

文字化け対応

日本語を含むPPTXファイルをアップロードした場合、コンテナ環境では、libreofficeがデフォルト設定のままでは日本語フォントを認識できないことがあります。

この問題を解決するには、fonts-noto-cjk パッケージをインストールしてください。これは Google Noto フォントのCJK(中日韓)版であり、日本語・中国語・韓国語の文字を高品質にレンダリングできます。

その後、fc-cache -f コマンドを実行してフォントキャッシュを強制的に更新すると、LibreOfficeが新しくインストールしたフォントを即座に認識できるようになります。

RUN apt-get update && apt-get install -y --no-install-recommends \
    libreoffice \
    imagemagick \
    ghostscript \
    curl \
    gosu \
    fonts-noto-cjk \
    && rm -rf /var/lib/apt/lists/* \
    && fc-cache -f \
...

サンプルリポジトリ

このリポジトリにはMastraではなく、AI SDKとBedrock providersを使用した実装コードがあります。ご興味があれば、ぜひご覧ください。

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?