Lambda で 日本語のPDF を作成してみる【TypeScript】

記事タイトルとURLをコピーする

はじめに

こんにちは、アプリケーションサービス本部 ディベロップメントサービス1課の北出です。
今回は、Lambda 関数内で PDF を作成し、 S3 バケットに出力する方法を紹介します。
言語は TypeScript で PDF 作成には pdf-lib を使用します。
デフォルトのフォントではすんなり PDF を作成できたのですが、デフォルトにはない日本語のフォントへの対応に苦戦したので備忘録もかねて紹介します。

pdf-lib とは

pdf-lib は、JavaScript で PDF ファイルを作成したり編集したりするためのライブラリです。既存の PDF に文字や画像を追加したり、新しいページを挿入したり、フォームに入力したりといった操作が可能です。また、まっさらな状態から新しい PDF ドキュメントを生成することもできます。特別なソフトウェアなしに、JavaScript だけで PDF を扱えるのが特徴です。

GitHub のリンク の GitHub の README に基本的な使い方が記述されています。
デフォルトのフォントならば、下のようにして PDF を作成できます。

import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'

// Create a new PDFDocument
const pdfDoc = await PDFDocument.create()

// Embed the Times Roman font
const timesRomanFont = await pdfDoc.embedFont(StandardFonts.TimesRoman)

// Add a blank page to the document
const page = pdfDoc.addPage()

// Get the width and height of the page
const { width, height } = page.getSize()

// Draw a string of text toward the top of the page
const fontSize = 30
page.drawText('Creating PDFs in JavaScript is awesome!', {
  x: 50,
  y: height - 4 * fontSize,
  size: fontSize,
  font: timesRomanFont,
  color: rgb(0, 0.53, 0.71),
})

// Serialize the PDFDocument to bytes (a Uint8Array)
const pdfBytes = await pdfDoc.save()

// For example, `pdfBytes` can be:
//   • Written to a file in Node
//   • Downloaded from the browser
//   • Rendered in an <iframe>

ただし、デフォルトのフォントでは日本語は使えず、帳票を作成するなどには使えません。
そのため、フォントを埋め込む必要があります。README では こちら に記載されています。

フォントの埋め込みとは

フォントの埋め込みとは、フォント定義する、.ttf.ttc ファイルをアプリケーション内で読み込んで使うことです。
そのため、Lambda で使うにはデプロイするパッケージに使用するフォントを含める必要があります。
また、 pdf-lib で埋め込みフォントを扱う場合、 @pdf-lib/fontkit というパッケージを追加でインストールする必要があります。

使用するフォントについて

今回使用するフォントについてですが、日本語フォントでよく知られているものとして、 MS ゴシック や メイリオ などがあります。しかし、Office でよく使われるフォントは Microsoft がライセンスを持っており、フォントの埋め込みが規約に抵触する可能性があります。参考: Microsoft のフォント再配布の FAQ
そのため、今回はライセンスに問題のない、Noto Sans JP を使用します。このフォントは Adobe と Google が共同開発したもので、こちら からダウンロードできます。

実際にやってみる

今回使用する主なファイルの構成は以下のようになっています。

.
├── bin
│   └── lambda-pdf.ts
├── lambda
│   ├── fonts
│   │   ├── NotoSansJP-Bold.ttf
│   │   └── NotoSansJP-Regular.ttf
│   └── index.ts
├── lib
│   └── lambda-pdf-stack.ts
├── package-lock.json
├── package.json
└── tsconfig.json

lib/lambda-pdf-stack.ts

Lambda や S3, IAM などのリソースを定義するファイルです。
Lambda の設定部分の bundling.commandHooksfonts/ ディレクトリもパッケージに含めるようにしています。

import * as cdk from 'aws-cdk-lib';
import { Construct, Node } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as path from 'node:path';

export class LambdaPdfStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // S3バケットの作成
    const bucket = new s3.Bucket(this, 'FileStorageBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY, // スタック削除時にバケットも削除(デモ用)
      autoDeleteObjects: true, // スタック削除時にオブジェクトも削除(デモ用)
    });

    // Lambda関数の定義
    const fileProcessorFunction = new NodejsFunction(this, 'FileProcessorFunction', {
      runtime: lambda.Runtime.NODEJS_22_X,
      entry: path.join(__dirname, '../lambda/index.ts'), // Lambda関数のエントリポイント
      handler: 'handler', // Lambda関数のハンドラー
      environment: {
        BUCKET_NAME: bucket.bucketName, // 環境変数としてバケット名を渡す
      },
      architecture: cdk.aws_lambda.Architecture.X86_64, // x86_64アーキテクチャを使用
      memorySize: 512, // メモリサイズ
      timeout: cdk.Duration.seconds(30), // タイムアウト時間
      bundling: {
        commandHooks: {
          beforeBundling(inputDir: string, outputDir: string): string[] {
            return [];
          },
          afterBundling(inputDir: string, outputDir: string): string[] {
            // fontsディレクトリを出力先にコピー
            return [
              `mkdir -p ${outputDir}/fonts`,
              `cp -r ${inputDir}/lambda/fonts/* ${outputDir}/fonts/`,
            ];
          },
          beforeInstall(inputDir: string, outputDir: string): string[] {
            return [];
          },
        },
      }
    });

    // CloudWatch Logsへの書き込み権限をAWS管理ポリシーを使って付与
    fileProcessorFunction.role?.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')
    );


    // S3バケットにアクセスするためのIAM権限をLambda関数に付与
    bucket.grantReadWrite(fileProcessorFunction);

    // 追加のIAMポリシーをアタッチ
    fileProcessorFunction.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          's3:ListBucket',
          's3:GetObject',
          's3:PutObject',
        ],
        resources: [
          bucket.bucketArn,
          `${bucket.bucketArn}/*`,
        ],
      })
    );
  }
}

lambda/index.ts

Lambda 関数の コード部分のファイルです。
フォントの埋め込み 部分や PDF のアップロード部分など参考ください。

// Lambda関数のエントリポイント
import * as AWS from 'aws-sdk';
import { PDFDocument, PageSizes, rgb } from "pdf-lib";
import fontkit from "@pdf-lib/fontkit";
import fs from "fs-extra";
import path from "node:path";

const s3 = new AWS.S3();

// Lambda関数のイベントハンドラー型定義
interface APIGatewayProxyEvent {
    body?: string;
    headers?: { [name: string]: string };
    httpMethod?: string;
    path?: string;
    queryStringParameters?: { [name: string]: string };
    pathParameters?: { [name: string]: string };
    // 必要に応じて他のプロパティも追加可能
}

// レスポンス型定義
interface APIGatewayProxyResult {
    statusCode: number;
    body: string;
    headers?: {
        [header: string]: string | boolean;
    };
}

// S3アップロード結果
interface S3UploadResult {
    Location: string;
    ETag: string;
    Bucket: string;
    Key: string;
}

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    console.log('イベント受信:', JSON.stringify(event, null, 2));

    try {
        // PDFを生成
        const pdfResult = await generatePDF();

        const fileContent: Buffer = Buffer.from(pdfResult);
        console.log('PDF生成成功:', fileContent);

        // ファイル名の設定
        const fileName: string = `sample-pdf-${Date.now()}.pdf`;

        // S3へのアップロード
        const uploadParams = {
            Bucket: process.env.BUCKET_NAME as string,
            Key: fileName,
            Body: fileContent,
            ContentType: 'application/pdf', // 実際のPDFでは 'application/pdf'
        };

        // S3へのアップロード実行
        const result: S3UploadResult = await s3.upload(uploadParams).promise();
        console.log('S3アップロード成功:', result);

        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'ファイルが正常にS3にアップロードされました',
                location: result.Location
            })
        };
    } catch (error) {
        console.error('エラー発生:', error);

        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'エラーが発生しました',
                error: error instanceof Error ? error.message : '不明なエラー'
            })
        };
    }
};

async function generatePDF(): Promise<Buffer> {
    const pdfDoc = await PDFDocument.create();
    pdfDoc.registerFontkit(fontkit);

    // フォントの埋め込み
    const fontBytes = await fs.readFileSync(path.join(__dirname, 'fonts', 'NotoSansJP-Regular.ttf'));
    const boldFontBytes = await fs.readFileSync(path.join(__dirname, 'fonts', 'NotoSansJP-Bold.ttf'));
    const regularFont = await pdfDoc.embedFont(fontBytes);
    const boldFont = await pdfDoc.embedFont(boldFontBytes);
    // A4サイズのページを作成(縦向き)
    const page = pdfDoc.addPage(PageSizes.A4);

    // ページサイズの取得とマージン設定
    const { width, height } = page.getSize();
    const margin = 10.0 * 2.83;

    const title = 'PDFタイトル'
    const fontsize = 14;
    // テキストの幅を取得
    const textWidth = boldFont.widthOfTextAtSize(title, fontsize);
    // テキストを描画
    page.drawText(title, {
        size: fontsize,
        font: boldFont,
        color: rgb(0, 0, 0),
        x: (width - textWidth) / 2, // 中央に配置
        y: height - margin, // 上部マージンから少し下
    });

    page.drawText('PDFの内容がここに入ります。', {
        x: margin,
        y: height - margin * 3,
        size: 12,
        font: regularFont,
        color: rgb(0, 0, 0),
    });

    // PDFをバイナリデータとして取得
    const pdfBytes = await pdfDoc.save();
    const pdfBuffer = Buffer.from(pdfBytes);
    return pdfBuffer;
}

ポイントとしては、 lib/lambda-pdf-stack.ts 内で埋め込むフォントファイルをLambdaに送るようにすることと、 lambda/index.ts 内での、fontkit の利用や、埋め込みの定義になります。

まとめ

Lambda での日本語フォントを使用したPDF作成はできるようになりましたが、pdf-lib はシンプルで軽量ですが、テキスト作成毎に毎回座標をしているする必要があり、特に表作成の部分で苦戦しました。 今後またLambda でPDF作成をすることがあれば、他のライブラリも探そうと思います。

北出 宏紀(執筆記事の一覧)

アプリケーションサービス本部ディベロップメントサービス3課

2024年9月中途入社です。